feat: explicit description attribute (+ #[EnumValue]) + SchemaFactory docblock toggle#793
Conversation
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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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.
description attribute + SchemaFactory docblock toggledescription attribute (+ #[EnumValue]) + SchemaFactory docblock toggle
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.
|
For completeness, I've gone ahead and implemented a new It's worth pointing out here, that, in the future, |
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:
Silent schema leakage. Dev-facing docblocks (implementation notes,
@seereferences,@deprecatedreminders, TODO comments) ship verbatim to public API consumers. The security motivation is summarized in comment on Graphql enum description #740:Inconsistent attribute API.
#[Field],#[Input],#[MagicField],#[SourceField]already accept an explicitdescriptionargument.#[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.
descriptionargument on every schema-defining attributeAbstractGraphQLElement(formerlyAbstractRequest, see renames below) gains a trailing optional?string $descriptionparameter, 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 onField/Input/SourceField.2.
#[EnumValue]attribute for enum case metadataNew attribute targeting
Attribute::TARGET_CLASS_CONSTANTcarries an explicitdescriptionanddeprecationReasonfor individual cases of a PHP 8.1+ native enum exposed as a GraphQL enum type:The name mirrors the GraphQL spec's term ("enum values" per §3.5.2,
__EnumValuein introspection,enumValuesfield) and webonyx/graphql-php's internalEnumValueDefinition. 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 intoEnumTypeMapper::mapByClassName().3. Deprecation advisory for enums that have not adopted
#[EnumValue]When a
#[Type]-mapped enum declares zero#[EnumValue]attributes across its cases,EnumTypeMapperemits a PHPE_USER_DEPRECATEDnotice. 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'svaluesconfig) — 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
truefor backwards compatibility. Matches the existingset*setter pattern inSchemaFactory(setAuthenticationService,setNamingStrategy,setGlobTTL, …). When disabled, only explicitdescriptionarguments populate the schema.5.
Utils\DescriptionResolverTiny 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:description: '…'wins.description: ''(empty string) also wins — deliberately publishes an empty description and blocks the docblock fallback at that site.null.SchemaFactory::createSchema()constructs one resolver instance and threads it throughFieldsBuilder,TypeGenerator,InputTypeGenerator,EnumTypeMapper, andTypeHandler. This fixesInputTypeGenerator::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 uniquenessA GraphQL type has exactly one description. When both
#[Type]and#[ExtendType](or multiple#[ExtendType]s on the same class) declare adescription,TypeGeneratorthrowsDuplicateDescriptionOnTypeExceptionat 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:
TypeGenerator,InputTypeGenerator, andEnumTypeMapperconstruct 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
descriptionterminology. Neither is used in userland application code, but custom middlewares or extensions may need updating.QueryFieldDescriptor/InputFieldDescriptor:$commentconstructor 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 oldcommentterminology was the odd one out.AbstractRequest→AbstractGraphQLElement: 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 togetGraphQLElementAnnotation()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 passnull(or simply omit the argument).Deferred to follow-up PRs
Kept out of this PR to keep the scope reviewable:
#[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.#[EnumType]annotation. Skipped in favour of the native PHP 8.1 enum +#[Type]/#[EnumValue]path, which is already covered byEnumTypeMapper.#[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.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
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).EnumValueTest— 5 unit cases (defaults / description only / deprecation only / both / empty-string description).Integration/DescriptionTest— 15 end-to-end cases over a freshSchemaFactory:#[Query],#[Mutation],#[Type],#[Field], native enum#[Type]#[EnumValue]supplying case-level description#[EnumValue]supplying case-level deprecation reason#[EnumValue]falling back to docblock#[EnumValue]attributes (viaexpectUserDeprecationMessageMatches)#[EnumValue]annotation — locks in the "one acknowledgement silences the notice" contractsetDocblockDescriptionsEnabled(false)suppressing docblock-only enum cases while keeping#[EnumValue]-supplied descriptions#[ExtendType(description: …)]supplying the description when the base#[Type]did notsetDocblockDescriptionsEnabled(false)suppressing every docblock path#[Type]+#[ExtendType]throwingDuplicateDescriptionOnTypeExceptionwith both sources namedTypeTest,ExtendTypeTest,FactoryTest,QueryTest) confirming thedescriptionargument +getDescription()/$attributes['description']paths.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 viacomposer cs-check && composer phpstan && phpunit.Documentation
website/docs/descriptions.md(sidebar: Usage → Descriptions) covering the explicit argument, docblock fallback, precedence rule,SchemaFactorytoggle,#[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.mdx—setDocblockDescriptionsEnabled()added to theSchemaFactorysetters example; short comment points to the new page.field-middlewares.md— descriptor method signatures updated.Checklist
descriptionattribute to annotations to override PHPDoc #453composer cs-checkcleancomposer phpstancleanvendor/bin/phpunit539/539 green