Skip to content

feat: explicit description attribute (+ #[EnumValue]) + SchemaFactory docblock toggle#793

Open
oojacoboo wants to merge 8 commits intothecodingmachine:masterfrom
oojacoboo:feat/explicit-description-attribute-and-toggle
Open

feat: explicit description attribute (+ #[EnumValue]) + SchemaFactory docblock toggle#793
oojacoboo wants to merge 8 commits intothecodingmachine:masterfrom
oojacoboo:feat/explicit-description-attribute-and-toggle

Conversation

@oojacoboo
Copy link
Copy Markdown
Collaborator

@oojacoboo oojacoboo commented Apr 18, 2026

Closes #453 and #740.

Problem

GraphQLite implicitly pulls the PHP docblock summary of every schema-defining annotation into the GraphQL schema as the element's description, with no way to opt out. This creates two problems:

  1. Silent schema leakage. Dev-facing docblocks (implementation notes, @see references, @deprecated reminders, TODO comments) ship verbatim to public API consumers. The security motivation is summarized in comment on Graphql enum description #740:

    Defaulting to phpdoc is a potential security issue with unintended consequences. And, IMO, should be disabled by default. It was a bad design choice.

  2. Inconsistent attribute API. #[Field], #[Input], #[MagicField], #[SourceField] already accept an explicit description argument. #[Query], #[Mutation], #[Subscription], #[Type], #[ExtendType], #[Factory] do not — the escape hatch is missing exactly where schemas need it most. Individual enum cases also have no attribute-level metadata.

Fix

1. description argument on every schema-defining attribute

AbstractGraphQLElement (formerly AbstractRequest, see renames below) gains a trailing optional ?string $description parameter, automatically adding the argument to #[Query], #[Mutation], #[Subscription], and #[Field]. #[Type], #[ExtendType], and #[Factory] each get the same parameter directly. The $attributes['description'] backfill mirrors the existing pattern on Field / Input / SourceField.

2. #[EnumValue] attribute for enum case metadata

New attribute targeting Attribute::TARGET_CLASS_CONSTANT carries an explicit description and deprecationReason for individual cases of a PHP 8.1+ native enum exposed as a GraphQL enum type:

#[Type]
enum Genre: string
{
    #[EnumValue(description: 'Fiction works including novels and short stories.')]
    case Fiction = 'fiction';

    #[EnumValue(deprecationReason: 'Use Fiction::Verse instead.')]
    case Poetry = 'poetry';

    case NonFiction = 'non-fiction'; // no attribute — description from docblock today, hidden after the future opt-in flip
}

The name mirrors the GraphQL spec's term ("enum values" per §3.5.2, __EnumValue in introspection, enumValues field) and webonyx/graphql-php's internal EnumValueDefinition. It matches graphqlite's existing GraphQL-spec-mirroring convention (#[Type], #[Field], #[Query], …) rather than the PHP language term "case". AnnotationReader::getEnumValueAnnotation(ReflectionEnumUnitCase) reads the attribute and is threaded into EnumTypeMapper::mapByClassName().

3. Deprecation advisory for enums that have not adopted #[EnumValue]

When a #[Type]-mapped enum declares zero #[EnumValue] attributes across its cases, EnumTypeMapper emits a PHP E_USER_DEPRECATED notice. This preserves today's behaviour — every case is still automatically exposed — but announces a planned opt-in migration: a future major release will require at least one #[EnumValue]-annotated case per #[Type]-mapped enum, and only annotated cases will participate in the schema (mirroring #[Field]'s opt-in model on classes).

Partial annotation is intentional and silent. Annotating even a single case acknowledges the migration and silences the notice; from that point any combination of annotated and unannotated cases is correct and intentional. Leaving some cases unannotated is the mechanism for hiding them from the public schema once the default flips — penalizing that pattern would defeat the whole point of selective exposure.

The practical benefit of the future opt-in model is selective exposure: an internal enum case can stay out of the public schema simply by omitting #[EnumValue], instead of forcing schema authors to split an enum or rename cases. After the flip, a resolver that returns an unannotated case triggers webonyx/graphql-php's standard enum serialization error (the value isn't in the enum type's values config) — the same spec-compliant behaviour that applies to any unknown enum value, and the mechanism that makes selective exposure safe.

Adopters who aren't ready to decide which cases to expose can silence the advisory with a bare #[EnumValue] on a single case (no description, no deprecation — just the acknowledgement). Matches graphqlite's existing deprecation pattern (e.g. addControllerNamespace, setGlobTTL).

4. SchemaFactory::setDocblockDescriptionsEnabled(bool $enabled)

New opt-out toggle, default true for backwards compatibility. Matches the existing set* setter pattern in SchemaFactory (setAuthenticationService, setNamingStrategy, setGlobTTL, …). When disabled, only explicit description arguments populate the schema.

5. Utils\DescriptionResolver

Tiny single-responsibility helper that encodes the precedence rule consistently across every extraction site. Constructor takes the toggle; resolve(?string $explicit, ?string $docblockDerived) applies the rule:

  1. Explicit description: '…' wins.
  2. Explicit description: '' (empty string) also wins — deliberately publishes an empty description and blocks the docblock fallback at that site.
  3. Docblock-derived value — only when the toggle is enabled.
  4. Otherwise null.

SchemaFactory::createSchema() constructs one resolver instance and threads it through FieldsBuilder, TypeGenerator, InputTypeGenerator, EnumTypeMapper, and TypeHandler. This fixes InputTypeGenerator::mapFactoryMethod() along the way — the long-standing // TODO: add comment argument. goes away and factory-produced input types now participate in description resolution.

6. #[ExtendType] description uniqueness

A GraphQL type has exactly one description. When both #[Type] and #[ExtendType] (or multiple #[ExtendType]s on the same class) declare a description, TypeGenerator throws DuplicateDescriptionOnTypeException at schema-build time, naming every offending source. Consumer code declaring a description on #[ExtendType] for a base #[Type] that did not provide one is the legitimate use case and is explicitly supported (e.g. an application describing a third-party library type). See the new Descriptions doc page for the full decision table.

Why this approach vs. a DescriptionFieldMiddleware?

The alternative middleware-based approach suggested on #453 was briefly considered but has two blocking gaps:

  1. Factories aren't covered. @withinboredom's own middleware sketch on InputTypeGenerator::mapFactoryMethod doesn't call field middleware. #439 notes: "However, in factories, it doesn't work because the field middleware doesn't appear to be called at all for the generated fields."
  2. Types aren't covered. TypeGenerator, InputTypeGenerator, and EnumTypeMapper construct types outside the field-middleware pipeline; there's nowhere for the middleware to intercept.

Only a first-class attribute argument + resolver pipeline can give the uniform coverage described in #740 ("All attributes that define a schema definition element should include a description argument.").

Breaking changes

Two internal renames bundled into the same PR for consistency with the new description terminology. Neither is used in userland application code, but custom middlewares or extensions may need updating.

  1. QueryFieldDescriptor / InputFieldDescriptor: $comment constructor parameter and property renamed to $description. getComment()getDescription(), withComment()withDescription(), withAddedCommentLines()withAddedDescriptionLines(). The GraphQL spec uses "description" everywhere (§2.5, introspection, webonyx config key); the old comment terminology was the odd one out.
  2. AbstractRequestAbstractGraphQLElement: the shared base for #[Query], #[Mutation], #[Subscription], and #[Field]. The new name accurately describes its role (it represents a GraphQL schema element with a return type and description), whereas "Request" collided with the more common HTTP meaning and misfit #[Field]. AnnotationReader::getRequestAnnotation() renamed to getGraphQLElementAnnotation() for symmetry.

An empty-string description: '' was previously indistinguishable from "no description provided" and fell through to the docblock. It now deliberately blocks the fallback and publishes an empty description. Users relying on the old behaviour should pass null (or simply omit the argument).

Deferred to follow-up PRs

Kept out of this PR to keep the scope reviewable:

  • Flipping the enum-case opt-in default. A future major release will require at least one #[EnumValue]-annotated case per #[Type]-mapped enum and hide unannotated cases from the schema. This PR only emits the advisory deprecation notice; the default flip is a separate breaking change.
  • Deprecated #[EnumType] annotation. Skipped in favour of the native PHP 8.1 enum + #[Type] / #[EnumValue] path, which is already covered by EnumTypeMapper.
  • #[Decorate] description. The attribute wraps an existing input-type definition and has no obvious single description target; a follow-up with clear semantics is better than adding it here.
  • Flipping setDocblockDescriptionsEnabled(false) as the default. A breaking change worth its own major-version release, per the security rationale on Graphql enum description #740.

Test plan

  • New DescriptionResolverTest — 8-case precedence matrix (explicit non-empty, explicit empty, null + toggle on, null + toggle off, null + null, null + empty docblock, plus the constructor getter).
  • New EnumValueTest — 5 unit cases (defaults / description only / deprecation only / both / empty-string description).
  • New Integration/DescriptionTest — 15 end-to-end cases over a fresh SchemaFactory:
    • explicit description on #[Query], #[Mutation], #[Type], #[Field], native enum #[Type]
    • #[EnumValue] supplying case-level description
    • #[EnumValue] supplying case-level deprecation reason
    • enum case without #[EnumValue] falling back to docblock
    • deprecation advisory fires on an enum that declares zero #[EnumValue] attributes (via expectUserDeprecationMessageMatches)
    • advisory stays silent when an enum has partial #[EnumValue] annotation — locks in the "one acknowledgement silences the notice" contract
    • setDocblockDescriptionsEnabled(false) suppressing docblock-only enum cases while keeping #[EnumValue]-supplied descriptions
    • #[ExtendType(description: …)] supplying the description when the base #[Type] did not
    • docblock fallback still populating descriptions by default (BC)
    • setDocblockDescriptionsEnabled(false) suppressing every docblock path
    • duplicate descriptions across #[Type] + #[ExtendType] throwing DuplicateDescriptionOnTypeException with both sources named
  • New unit tests per annotation (TypeTest, ExtendTypeTest, FactoryTest, QueryTest) confirming the description argument + getDescription() / $attributes['description'] paths.
  • Existing 502 tests pass unchanged. Pre-existing enum fixtures (Color, Size, Position) gained a bare #[EnumValue] acknowledgement on one case each — documents the minimal upgrade path and keeps test output clean. Full suite is now 539 tests, 1244 assertions, green on PHP 8.2–8.5 via composer cs-check && composer phpstan && phpunit.

Documentation

  • New page website/docs/descriptions.md (sidebar: Usage → Descriptions) covering the explicit argument, docblock fallback, precedence rule, SchemaFactory toggle, #[EnumValue] enum-case metadata, the planned enum-case opt-in migration with its advisory notice, and #[ExtendType] uniqueness rule.
  • annotations-reference.md — each affected attribute's table row links to the new page; #[EnumValue] section added; cross-cutting concept pulled out of the reference list where it didn't belong.
  • other-frameworks.mdxsetDocblockDescriptionsEnabled() added to the SchemaFactory setters example; short comment points to the new page.
  • field-middlewares.md — descriptor method signatures updated.

Checklist

Adds explicit `description` argument to every schema-defining attribute
(Query, Mutation, Subscription, Type, ExtendType, Factory) and a
SchemaFactory toggle to disable the docblock-as-description fallback.

Addresses thecodingmachine#453 and the type-level portion of thecodingmachine#740.

Behaviour highlights:
- Explicit attribute description always wins; `''` deliberately blocks
  the docblock fallback; `null` falls through when the toggle is on.
- `setDocblockDescriptionsEnabled(false)` suppresses every docblock
  fallback path so internal developer docblocks stop leaking to public
  schema consumers.
- Duplicate descriptions across `#[Type]` + `#[ExtendType]` (or multiple
  `#[ExtendType]`s on the same class) now throw a
  DuplicateDescriptionOnTypeException naming every offending source.
- `InputTypeGenerator::mapFactoryMethod()` closes the long-standing
  `// TODO: add comment argument.` — factory-produced input types now
  participate in description resolution.

Consistency renames (breaking):
- `QueryFieldDescriptor`/`InputFieldDescriptor` `$comment` -> `$description`
  and paired method renames (getDescription/withDescription/
  withAddedDescriptionLines).
- `AbstractRequest` -> `AbstractGraphQLElement`
  and `AnnotationReader::getRequestAnnotation()` ->
  `getGraphQLElementAnnotation()`. The new name reflects the class's
  role as the shared base for GraphQL schema-element attributes.

Tests: +26 new (resolver precedence matrix, per-attribute integration,
conflict exception, docblock-off regression). Full suite 528 passing
across cs-check, phpstan, and phpunit.
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 18, 2026

Codecov Report

❌ Patch coverage is 97.97980% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.87%. Comparing base (53f9d49) to head (159f237).
⚠️ Report is 144 commits behind head on master.

Files with missing lines Patch % Lines
src/InputFieldDescriptor.php 50.00% 2 Missing ⚠️
src/Mappers/Parameters/TypeHandler.php 85.71% 1 Missing ⚠️
src/Mappers/Root/EnumTypeMapper.php 96.77% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master     #793      +/-   ##
============================================
- Coverage     95.72%   94.87%   -0.86%     
- Complexity     1773     1895     +122     
============================================
  Files           154      178      +24     
  Lines          4586     5010     +424     
============================================
+ Hits           4390     4753     +363     
- Misses          196      257      +61     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Closes the deeper portion of thecodingmachine#740 that the original PR left as future work:
native PHP 8.1+ enum cases can now carry an explicit GraphQL description
and deprecation reason without relying on docblock parsing.

```php
#[Type]
enum Genre: string
{
    #[EnumValue(description: 'Fiction works including novels and short stories.')]
    case Fiction = 'fiction';

    #[EnumValue(deprecationReason: 'Use Fiction::Verse instead.')]
    case Poetry = 'poetry';
}
```

Naming: follows the GraphQL specification's term ("enum values", §3.5.2,
`__EnumValue`, `enumValues`) rather than the PHP language term "case".
This matches every other graphqlite attribute (`Type`, `Field`, `Query`,
`ExtendType`, …) which mirrors GraphQL spec names, and webonyx/graphql-php's
internal `EnumValueDefinition`. Targets `Attribute::TARGET_CLASS_CONSTANT`
so it applies to PHP enum cases.

Precedence: explicit `description` / `deprecationReason` on the attribute
win over docblock summary / `@deprecated` tag. Passing `''` deliberately
suppresses the fallback at that site, matching every other `description`
argument across the library. No-attribute cases continue to fall back to
docblock — BC preserved.

Adds `AnnotationReader::getEnumValueAnnotation(ReflectionEnumUnitCase)`
helper and threads it through `EnumTypeMapper::mapByClassName()` where
enum case metadata is aggregated.

Tests: 5 new unit tests (attribute defaults / description / deprecation
reason / both / empty string) plus 4 new integration tests over a new
fixture enum (attribute-supplied description, docblock fallback, explicit
deprecation, toggle-off suppresses docblock-only case). Full suite
537/537 green across cs-check, phpstan, and phpunit.
@oojacoboo oojacoboo changed the title feat: explicit description attribute + SchemaFactory docblock toggle feat: explicit description attribute (+ #[EnumValue]) + SchemaFactory docblock toggle Apr 18, 2026
Announces the upcoming opt-in migration for PHP enums mapped to GraphQL
enum types. Today every case is automatically exposed; a future major
release will require #[EnumValue] on each case that should participate
in the schema, matching the opt-in model #[Field] already uses for
methods and properties on object types.

The practical win is selective exposure: an internal enum case can opt
out of the public schema simply by omitting #[EnumValue], instead of
forcing schema authors to split an enum or rename cases.

Runtime behaviour is unchanged. When an enum annotated with #[Type]
exposes any case without an #[EnumValue] attribute, EnumTypeMapper
emits an E_USER_DEPRECATED notice that names the specific cases so the
migration path is mechanical — matching graphqlite's existing
deprecation pattern (e.g. addControllerNamespace, setGlobTTL).

Tests: +1 explicit deprecation assertion (using PHPUnit 11's
expectUserDeprecationMessageMatches). Existing integration tests that
exercise the Genre fixture surface the notice via PHPUnit's deprecation
reporting, confirming the warning fires in realistic scenarios. Full
suite 538/538 green across cs-check, phpstan, and phpunit; 4 intentional
deprecation events reported.

Docs: descriptions.md gains a "Future migration" subsection describing
the planned opt-in model and the current advisory notice.
…flip

The advisory deprecation already announces the opt-in migration. Add a
second paragraph spelling out what happens if a resolver returns a case
that lacks #[EnumValue] after the default flips: webonyx/graphql-php's
native enum serialization rejects it, the same spec-compliant behaviour
that applies to any unknown enum value. That is the mechanism that makes
selective exposure safe — internal cases cannot leak via a resolver —
and clarifies that omitting #[EnumValue] is a deliberate 'do not expose
this value' signal, not an oversight to work around.
…lue]

Partial annotation is the mechanism that will hide internal cases from
the public schema once the default flips to opt-in; leaving some cases
unannotated is deliberate and must not produce a warning. Under the
previous logic the advisory fired for every partial annotation, which
would punish exactly the pattern the migration encourages.

New rule: fire the E_USER_DEPRECATED advisory only when a #[Type]-mapped
enum declares zero #[EnumValue] attributes across all its cases — the
signal that the developer has not yet engaged with the opt-in model.
Annotating even a single case acknowledges the migration and silences
the notice; from that point any combination of annotated and unannotated
cases is correct and intentional.

Implementation: EnumTypeMapper::mapByClassName() now tracks a single
`sawAnyEnumValueAttribute` flag while iterating cases and emits the
notice only when the flag stays false. Message rewritten to explain the
"at least one case" requirement and point the reader at the intended
migration path — including the fact that a bare #[EnumValue] on a
single case is a valid acknowledgement.

Tests:
- Replaces the previous "any case missing" assertion with
  testEnumWithZeroEnumValueAttributesTriggersDeprecation using a new
  DescriptionLegacyEnum/Era fixture that declares no #[EnumValue] at
  all.
- Adds testEnumWithPartialEnumValueAttributesIsSilent to lock in the
  partial-annotation contract against regressions.
- Pre-existing Color/Size/Position fixtures gain a bare #[EnumValue] on
  their first case so their integration tests stop triggering the
  advisory — demonstrates the minimal upgrade path recommended in the
  docs.

Full suite 539/539 green across cs-check, phpstan, and phpunit, with
exactly 1 intentional deprecation coming from the dedicated advisory
test. The docs in descriptions.md are updated to reflect the corrected
semantics — "partial annotation is intentional and silent".
Matches PHP's isser/haser naming convention and reads as a present-state
question instead of imperative history. Also aligns the private helper
name with the boolean semantics (plural -> singular since the check is
'has at least one attribute anywhere on the enum').
…ndation

Recommending users annotate a single case to silence the advisory is
actively misleading: once the default flips, every other case would
disappear from the schema — the exact opposite of what a user reaching
for the shortcut wanted. The honest recommendation is 'annotate every
case you want to keep exposed', which aligns their intent with the
post-flip behaviour.

- Warning message rewritten around 'add to every case you want exposed,
  omit only from cases you want hidden' instead of 'at least one case
  silences this notice'.
- descriptions.md migration subsection mirrors the same framing.
- Color/Size/Position test fixtures now annotate every case, not just
  the first one, so they demonstrate the correct migration pattern
  rather than the misleading shortcut.
@oojacoboo
Copy link
Copy Markdown
Collaborator Author

For completeness, I've gone ahead and implemented a new #[EnumValue] attribute that supports description. This was the only remaining item not covered for descriptions and should fully solve #740.

It's worth pointing out here, that, in the future, #[EnumValue] attributes will be required for all cases/values that are to be exposed over the API. This follows the opt-in pattern for #[Field] and keeps a consistent mental model for defining a schema. Additionally, it allows for only exposing certain cases/values, where you may only want some for internal/backend support, but not publicly exposed in your graph. For now, a deprecation notice will be thrown to let developers know that this needs to be updated. In a future version, this will be flipped, and an error will be thrown for enums defined with a #[Type] attribute, but no #[EnumValue] attributes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Graphql enum description Add description attribute to annotations to override PHPDoc

2 participants