From b49926c6560eec7ba370f3981e0992904bc7a415 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 14 Jun 2026 15:50:33 +0200 Subject: [PATCH 01/14] Fix SingleFieldSubscription to expand fragments and reject introspection/@skip/@include MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .../Rules/SingleFieldSubscription.php | 174 ++++++++++- .../SingleFieldSubscriptionsTest.php | 292 +++++++++++++++++- tests/Validator/ValidatorTestCase.php | 2 + 3 files changed, 459 insertions(+), 9 deletions(-) diff --git a/src/Validator/Rules/SingleFieldSubscription.php b/src/Validator/Rules/SingleFieldSubscription.php index fa4ab1cce..bf152b2db 100644 --- a/src/Validator/Rules/SingleFieldSubscription.php +++ b/src/Validator/Rules/SingleFieldSubscription.php @@ -3,10 +3,20 @@ namespace GraphQL\Validator\Rules; use GraphQL\Error\Error; +use GraphQL\Language\AST\DirectiveNode; +use GraphQL\Language\AST\FieldNode; +use GraphQL\Language\AST\FragmentDefinitionNode; +use GraphQL\Language\AST\FragmentSpreadNode; +use GraphQL\Language\AST\InlineFragmentNode; +use GraphQL\Language\AST\Node; use GraphQL\Language\AST\NodeKind; +use GraphQL\Language\AST\NodeList; use GraphQL\Language\AST\OperationDefinitionNode; +use GraphQL\Language\AST\SelectionNode; +use GraphQL\Language\AST\SelectionSetNode; use GraphQL\Language\Visitor; use GraphQL\Language\VisitorOperation; +use GraphQL\Type\Definition\Directive; use GraphQL\Validator\QueryValidationContext; class SingleFieldSubscription extends ValidationRule @@ -16,15 +26,81 @@ public function getVisitor(QueryValidationContext $context): array return [ NodeKind::OPERATION_DEFINITION => static function (OperationDefinitionNode $node) use ($context): VisitorOperation { if ($node->operation === 'subscription') { - $selections = $node->selectionSet->selections; + $schema = $context->getSchema(); + $subscriptionType = $schema->getSubscriptionType(); - if (count($selections) > 1) { - $offendingSelections = $selections->splice(1, count($selections)); + if ($subscriptionType !== null) { + $operationName = $node->name->value ?? null; - $context->reportError(new Error( - static::multipleFieldsInOperation($node->name->value ?? null), - $offendingSelections - )); + // Collect fragment definitions from document + /** @var array $fragments */ + $fragments = []; + foreach ($context->getDocument()->definitions as $definition) { + if ($definition instanceof FragmentDefinitionNode) { + $fragments[$definition->name->value] = $definition; + } + } + + // Check for @skip/@include on top-level selections + /** @var array $forbiddenDirectiveNodes */ + $forbiddenDirectiveNodes = []; + foreach ($node->selectionSet->selections as $selection) { + $directives = self::getDirectives($selection); + foreach ($directives as $directive) { + $directiveName = $directive->name->value; + if ($directiveName === Directive::SKIP_NAME || $directiveName === Directive::INCLUDE_NAME) { + $forbiddenDirectiveNodes[] = $directive; + } + } + } + + if ($forbiddenDirectiveNodes !== []) { + $context->reportError(new Error( + static::skipIncludeInOperation($operationName), + $forbiddenDirectiveNodes + )); + + return Visitor::skipNode(); + } + + // Collect fields by expanding fragments + /** @var array> $groupedFieldSet */ + $groupedFieldSet = []; + /** @var array $visitedFragments */ + $visitedFragments = []; + self::collectFields( + $node->selectionSet, + $fragments, + $groupedFieldSet, + $visitedFragments + ); + + if (count($groupedFieldSet) > 1) { + $keys = array_keys($groupedFieldSet); + /** @var array $extraFieldNodes */ + $extraFieldNodes = []; + for ($keyIndex = 1, $keyCount = count($keys); $keyIndex < $keyCount; ++$keyIndex) { + foreach ($groupedFieldSet[$keys[$keyIndex]] as $fieldNode) { + $extraFieldNodes[] = $fieldNode; + } + } + + $context->reportError(new Error( + static::multipleFieldsInOperation($operationName), + $extraFieldNodes + )); + } + + // Check for introspection fields + foreach ($groupedFieldSet as $fieldNodes) { + $fieldName = $fieldNodes[0]->name->value; + if ($fieldName[0] === '_' && ($fieldName[1] ?? '') === '_') { + $context->reportError(new Error( + static::introspectionFieldInOperation($operationName), + $fieldNodes + )); + } + } } } @@ -33,6 +109,72 @@ public function getVisitor(QueryValidationContext $context): array ]; } + /** + * @param SelectionNode&Node $selection + * + * @return NodeList + */ + private static function getDirectives($selection): NodeList + { + if ($selection instanceof FieldNode + || $selection instanceof FragmentSpreadNode + || $selection instanceof InlineFragmentNode + ) { + return $selection->directives; + } + + return new NodeList([]); + } + + /** + * @param array $fragments + * @param array> $groupedFieldSet + * @param array $visitedFragments + */ + private static function collectFields( + SelectionSetNode $selectionSet, + array $fragments, + array &$groupedFieldSet, + array &$visitedFragments + ): void { + foreach ($selectionSet->selections as $selection) { + if ($selection instanceof FieldNode) { + $responseKey = $selection->alias->value ?? $selection->name->value; + if (! isset($groupedFieldSet[$responseKey])) { + $groupedFieldSet[$responseKey] = []; + } + + $groupedFieldSet[$responseKey][] = $selection; + } elseif ($selection instanceof InlineFragmentNode) { + self::collectFields( + $selection->selectionSet, + $fragments, + $groupedFieldSet, + $visitedFragments + ); + } elseif ($selection instanceof FragmentSpreadNode) { + $fragmentName = $selection->name->value; + if (isset($visitedFragments[$fragmentName])) { + continue; + } + + $visitedFragments[$fragmentName] = true; + + if (! isset($fragments[$fragmentName])) { + continue; + } + + $fragment = $fragments[$fragmentName]; + self::collectFields( + $fragment->selectionSet, + $fragments, + $groupedFieldSet, + $visitedFragments + ); + } + } + } + public static function multipleFieldsInOperation(?string $operationName): string { if ($operationName === null) { @@ -41,4 +183,22 @@ public static function multipleFieldsInOperation(?string $operationName): string return "Subscription \"{$operationName}\" must select only one top level field."; } + + public static function introspectionFieldInOperation(?string $operationName): string + { + if ($operationName === null) { + return 'Anonymous Subscription must not select an introspection top level field.'; + } + + return "Subscription \"{$operationName}\" must not select an introspection top level field."; + } + + public static function skipIncludeInOperation(?string $operationName): string + { + if ($operationName === null) { + return 'Anonymous Subscription must not use `@skip` or `@include` directives in the top level selection.'; + } + + return "Subscription \"{$operationName}\" must not use `@skip` or `@include` directives in the top level selection."; + } } diff --git a/tests/Validator/SingleFieldSubscriptionsTest.php b/tests/Validator/SingleFieldSubscriptionsTest.php index 42ab5c338..c353bfff1 100644 --- a/tests/Validator/SingleFieldSubscriptionsTest.php +++ b/tests/Validator/SingleFieldSubscriptionsTest.php @@ -4,6 +4,9 @@ use GraphQL\Language\SourceLocation; use GraphQL\Tests\ErrorHelper; +use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\Type; +use GraphQL\Type\Schema; use GraphQL\Validator\Rules\SingleFieldSubscription; /** @@ -37,9 +40,9 @@ public function testValidSingleFieldBulkSubscriptions(): void meows } } - + subscription sub2 { - dogSubscribe { + barkSubscribe { barks } } @@ -78,6 +81,62 @@ public function testValidSingleFieldSubscriptionWithMultipleResultFields(): void ); } + /** @see it('valid subscription with fragment') */ + public function testValidSubscriptionWithFragment(): void + { + $this->expectPassesRule( + new SingleFieldSubscription(), + ' + subscription sub { + ...fragA + } + fragment fragA on SubscriptionRoot { + catSubscribe { + meows + } + } + ' + ); + } + + /** @see it('valid subscription with fragment and field') */ + public function testValidSubscriptionWithFragmentAndField(): void + { + $this->expectPassesRule( + new SingleFieldSubscription(), + ' + subscription sub { + ...fragA + catSubscribe { + meows + } + } + fragment fragA on SubscriptionRoot { + catSubscribe { + meows + } + } + ' + ); + } + + /** @see it('valid subscription with inline fragment') */ + public function testValidSubscriptionWithInlineFragment(): void + { + $this->expectPassesRule( + new SingleFieldSubscription(), + ' + subscription sub { + ... on SubscriptionRoot { + catSubscribe { + meows + } + } + } + ' + ); + } + /** @see it('invalid multiple field subscription') */ public function testInvalidMultipleFieldSubscription(): void { @@ -160,6 +219,201 @@ public function testInvalidManyFieldAnonymousSubscription(): void ); } + /** @see it('fails with many more than one root field via fragments') */ + public function testInvalidManyFieldsViaFragments(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + ...fragA + ...fragB + } + fragment fragA on SubscriptionRoot { + catSubscribe { + meows + } + } + fragment fragB on SubscriptionRoot { + barkSubscribe { + barks + } + } + ', + [$this->multipleFieldsInOperation('sub', [12, 9])] + ); + } + + /** @see it('does not infinite loop on recursive fragments') */ + public function testDoesNotInfiniteLoopOnRecursiveFragments(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + ...fragA + } + fragment fragA on SubscriptionRoot { + catSubscribe { + meows + } + ...fragA + ...fragB + } + fragment fragB on SubscriptionRoot { + barkSubscribe { + barks + } + ...fragA + } + ', + [$this->multipleFieldsInOperation('sub', [13, 9])] + ); + } + + /** @see it('fails with introspection field') */ + public function testFailsWithIntrospectionField(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + __typename + } + ', + [$this->introspectionFieldInOperation('sub', [3, 9])] + ); + } + + /** @see it('fails with introspection field anonymous') */ + public function testFailsWithIntrospectionFieldAnonymous(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription { + __typename + } + ', + [$this->introspectionFieldInOperation(null, [3, 9])] + ); + } + + /** @see it('fails with more than one root field including introspection') */ + public function testFailsWithMoreThanOneRootFieldIncludingIntrospection(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + catSubscribe { + meows + } + __typename + } + ', + [ + $this->multipleFieldsInOperation('sub', [6, 9]), + $this->introspectionFieldInOperation('sub', [6, 9]), + ] + ); + } + + /** @see it('fails with more than one root field including aliased introspection via fragment') */ + public function testFailsWithAliasedIntrospectionViaFragment(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + ...fragA + } + fragment fragA on SubscriptionRoot { + catSubscribe { + meows + } + name: __typename + } + ', + [ + $this->multipleFieldsInOperation('sub', [9, 9]), + $this->introspectionFieldInOperation('sub', [9, 9]), + ] + ); + } + + /** @see it('fails with @skip directive') */ + public function testFailsWithSkipDirective(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + catSubscribe @skip(if: true) { + meows + } + } + ', + [$this->skipIncludeInOperation('sub', [3, 22])] + ); + } + + /** @see it('fails with @include directive') */ + public function testFailsWithIncludeDirective(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + catSubscribe @include(if: true) { + meows + } + } + ', + [$this->skipIncludeInOperation('sub', [3, 22])] + ); + } + + /** @see it('fails with @skip directive anonymous') */ + public function testFailsWithSkipDirectiveAnonymous(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription { + catSubscribe @skip(if: true) { + meows + } + } + ', + [$this->skipIncludeInOperation(null, [3, 22])] + ); + } + + /** @see it('skips if not subscription type') */ + public function testSkipsIfNotSubscriptionType(): void + { + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'QueryRoot', + 'fields' => [ + 'foo' => ['type' => Type::string()], + ], + ]), + ]); + + $this->expectPassesRuleWithSchema( + $schema, + new SingleFieldSubscription(), + ' + subscription { + foo + bar + } + ' + ); + } + /** * @param array ...$locations A tuple of line and column * @@ -176,4 +430,38 @@ private function multipleFieldsInOperation(?string $operationName, array ...$loc }, $locations) ); } + + /** + * @param array ...$locations A tuple of line and column + * + * @phpstan-return ErrorArray + */ + private function introspectionFieldInOperation(?string $operationName, array ...$locations): array + { + return ErrorHelper::create( + SingleFieldSubscription::introspectionFieldInOperation($operationName), + array_map(static function (array $location): SourceLocation { + [$line, $column] = $location; + + return new SourceLocation($line, $column); + }, $locations) + ); + } + + /** + * @param array ...$locations A tuple of line and column + * + * @phpstan-return ErrorArray + */ + private function skipIncludeInOperation(?string $operationName, array ...$locations): array + { + return ErrorHelper::create( + SingleFieldSubscription::skipIncludeInOperation($operationName), + array_map(static function (array $location): SourceLocation { + [$line, $column] = $location; + + return new SourceLocation($line, $column); + }, $locations) + ); + } } diff --git a/tests/Validator/ValidatorTestCase.php b/tests/Validator/ValidatorTestCase.php index afab588c5..b3bbc115e 100644 --- a/tests/Validator/ValidatorTestCase.php +++ b/tests/Validator/ValidatorTestCase.php @@ -361,6 +361,8 @@ public static function getTestSchema(): Schema 'fields' => [ 'catSubscribe' => ['type' => $Cat], 'barkSubscribe' => ['type' => $Dog], + 'newMessage' => ['type' => Type::string()], + 'disallowedSecondRootField' => ['type' => Type::string()], ], ]); From 4f02ea87c61ae53d05eea859a9c4369e79c829b2 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 14 Jun 2026 15:51:59 +0200 Subject: [PATCH 02/14] Add CHANGELOG entry for SingleFieldSubscription fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 623205f0e..685698bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Fixed + +- Fix `SingleFieldSubscription` validation to expand fragments before counting root fields, reject introspection fields, and reject `@skip`/`@include` at the subscription root — completing full compliance with the [October 2021 GraphQL specification](https://spec.graphql.org/October2021/) + ## v15.33.0 ### Added From bd18ac73bf2e3291a29b0f1f89b3ae6f7ba9ab24 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 14 Jun 2026 15:52:59 +0200 Subject: [PATCH 03/14] Add PR link to CHANGELOG entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 685698bc2..1b399456d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ You can find and compare releases at the [GitHub release page](https://github.co ### Fixed -- Fix `SingleFieldSubscription` validation to expand fragments before counting root fields, reject introspection fields, and reject `@skip`/`@include` at the subscription root — completing full compliance with the [October 2021 GraphQL specification](https://spec.graphql.org/October2021/) +- Fix `SingleFieldSubscription` validation to expand fragments before counting root fields, reject introspection fields, and reject `@skip`/`@include` at the subscription root — completing full compliance with the [October 2021 GraphQL specification](https://spec.graphql.org/October2021/) https://github.com/webonyx/graphql-php/pull/1930 ## v15.33.0 From 83df5e5ceb9127624739c9d678ac1bf07071f82f Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 14 Jun 2026 16:01:42 +0200 Subject: [PATCH 04/14] Remove unused fields from test schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- tests/Validator/ValidatorTestCase.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/Validator/ValidatorTestCase.php b/tests/Validator/ValidatorTestCase.php index b3bbc115e..afab588c5 100644 --- a/tests/Validator/ValidatorTestCase.php +++ b/tests/Validator/ValidatorTestCase.php @@ -361,8 +361,6 @@ public static function getTestSchema(): Schema 'fields' => [ 'catSubscribe' => ['type' => $Cat], 'barkSubscribe' => ['type' => $Dog], - 'newMessage' => ['type' => Type::string()], - 'disallowedSecondRootField' => ['type' => Type::string()], ], ]); From 91f62cede9273495306680bd3fdac49b55660bb4 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 14 Jun 2026 16:29:54 +0200 Subject: [PATCH 05/14] Check fragment type conditions in collectFields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches graphql-js behavior: only expand inline fragments and fragment spreads whose type condition matches the subscription root type. 🤖 Generated with Claude Code --- .../Rules/SingleFieldSubscription.php | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/Validator/Rules/SingleFieldSubscription.php b/src/Validator/Rules/SingleFieldSubscription.php index bf152b2db..1b0c7f1c1 100644 --- a/src/Validator/Rules/SingleFieldSubscription.php +++ b/src/Validator/Rules/SingleFieldSubscription.php @@ -16,7 +16,10 @@ use GraphQL\Language\AST\SelectionSetNode; use GraphQL\Language\Visitor; use GraphQL\Language\VisitorOperation; +use GraphQL\Type\Definition\AbstractType; use GraphQL\Type\Definition\Directive; +use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Schema; use GraphQL\Validator\QueryValidationContext; class SingleFieldSubscription extends ValidationRule @@ -69,6 +72,8 @@ public function getVisitor(QueryValidationContext $context): array /** @var array $visitedFragments */ $visitedFragments = []; self::collectFields( + $schema, + $subscriptionType, $node->selectionSet, $fragments, $groupedFieldSet, @@ -130,8 +135,12 @@ private static function getDirectives($selection): NodeList * @param array $fragments * @param array> $groupedFieldSet * @param array $visitedFragments + * + * @throws \Exception */ private static function collectFields( + Schema $schema, + ObjectType $runtimeType, SelectionSetNode $selectionSet, array $fragments, array &$groupedFieldSet, @@ -146,7 +155,13 @@ private static function collectFields( $groupedFieldSet[$responseKey][] = $selection; } elseif ($selection instanceof InlineFragmentNode) { + if (! self::doesFragmentConditionMatch($schema, $selection, $runtimeType)) { + continue; + } + self::collectFields( + $schema, + $runtimeType, $selection->selectionSet, $fragments, $groupedFieldSet, @@ -165,7 +180,13 @@ private static function collectFields( } $fragment = $fragments[$fragmentName]; + if (! self::doesFragmentConditionMatch($schema, $fragment, $runtimeType)) { + continue; + } + self::collectFields( + $schema, + $runtimeType, $fragment->selectionSet, $fragments, $groupedFieldSet, @@ -175,6 +196,30 @@ private static function collectFields( } } + /** + * @param InlineFragmentNode|FragmentDefinitionNode $fragment + * + * @throws \Exception + */ + private static function doesFragmentConditionMatch(Schema $schema, Node $fragment, ObjectType $type): bool + { + $typeConditionNode = $fragment->typeCondition; + if ($typeConditionNode === null) { + return true; + } + + $conditionalType = $schema->getType($typeConditionNode->name->value); + if ($conditionalType === $type) { + return true; + } + + if ($conditionalType instanceof AbstractType) { + return $schema->isSubType($conditionalType, $type); + } + + return false; + } + public static function multipleFieldsInOperation(?string $operationName): string { if ($operationName === null) { From b9d26c93866b27d3c12d840805c10f169f7a6a88 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 14 Jun 2026 16:41:38 +0200 Subject: [PATCH 06/14] Add tests for fragment type condition matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the no-type-condition and non-matching type condition paths to improve patch coverage. 🤖 Generated with Claude Code --- .../SingleFieldSubscriptionsTest.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/Validator/SingleFieldSubscriptionsTest.php b/tests/Validator/SingleFieldSubscriptionsTest.php index c353bfff1..075cc2426 100644 --- a/tests/Validator/SingleFieldSubscriptionsTest.php +++ b/tests/Validator/SingleFieldSubscriptionsTest.php @@ -390,6 +390,41 @@ public function testFailsWithSkipDirectiveAnonymous(): void ); } + /** @see it('valid subscription with inline fragment without type condition') */ + public function testValidSubscriptionWithInlineFragmentWithoutTypeCondition(): void + { + $this->expectPassesRule( + new SingleFieldSubscription(), + ' + subscription sub { + ... { + catSubscribe { + meows + } + } + } + ' + ); + } + + /** @see it('ignores fields from non-matching fragment type condition') */ + public function testIgnoresFieldsFromNonMatchingFragmentTypeCondition(): void + { + $this->expectPassesRule( + new SingleFieldSubscription(), + ' + subscription sub { + catSubscribe { + meows + } + ... on Cat { + meows + } + } + ' + ); + } + /** @see it('skips if not subscription type') */ public function testSkipsIfNotSubscriptionType(): void { From f4ec360e590ec9b51998996ee917577225d13f17 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 14 Jun 2026 16:43:47 +0200 Subject: [PATCH 07/14] Split CHANGELOG entry into two lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b399456d..1f5f279f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ You can find and compare releases at the [GitHub release page](https://github.co ### Fixed -- Fix `SingleFieldSubscription` validation to expand fragments before counting root fields, reject introspection fields, and reject `@skip`/`@include` at the subscription root — completing full compliance with the [October 2021 GraphQL specification](https://spec.graphql.org/October2021/) https://github.com/webonyx/graphql-php/pull/1930 +- Fix `SingleFieldSubscription` validation to expand fragments before counting root fields, reject introspection fields, and reject `@skip`/`@include` at the subscription root https://github.com/webonyx/graphql-php/pull/1930 +- This release completes full compliance with the [October 2021 GraphQL specification](https://spec.graphql.org/October2021/) ## v15.33.0 From 1910986b52975e069ad4f45da814e7f88ce3efa3 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 14 Jun 2026 16:45:55 +0200 Subject: [PATCH 08/14] Remove trailing slashes from URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- CHANGELOG.md | 12 ++++++------ docs/complementary-tools.md | 4 ++-- docs/concepts.md | 2 +- docs/data-fetching.md | 6 +++--- docs/executing-queries.md | 8 ++++---- docs/getting-started.md | 2 +- docs/index.md | 4 ++-- docs/security.md | 2 +- examples/04-async-php/amphp/http-server/README.md | 2 +- src/Type/Definition/Directive.php | 2 +- tests/Utils/SchemaPrinterTest.php | 2 +- 11 files changed, 23 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f5f279f2..d2db2934e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ You can find and compare releases at the [GitHub release page](https://github.co ### Fixed - Fix `SingleFieldSubscription` validation to expand fragments before counting root fields, reject introspection fields, and reject `@skip`/`@include` at the subscription root https://github.com/webonyx/graphql-php/pull/1930 -- This release completes full compliance with the [October 2021 GraphQL specification](https://spec.graphql.org/October2021/) +- This release completes full compliance with the [October 2021 GraphQL specification](https://spec.graphql.org/October2021) ## v15.33.0 @@ -988,7 +988,7 @@ This release brings several breaking changes. Please refer to [UPGRADE](UPGRADE. - Support repeatable directives (https://github.com/webonyx/graphql-php/issues/643) - Support SDL Validation and other schema validation improvements (e.g. https://github.com/webonyx/graphql-php/issues/492) -- Added promise adapter for [Amp](https://amphp.org/) (https://github.com/webonyx/graphql-php/issues/551) +- Added promise adapter for [Amp](https://amphp.org) (https://github.com/webonyx/graphql-php/issues/551) - Query plan utility improvements (https://github.com/webonyx/graphql-php/issues/513, https://github.com/webonyx/graphql-php/issues/632) - Allow retrieving query complexity once query has been completed (https://github.com/webonyx/graphql-php/issues/316) - Allow input types to be passed in from variables using \stdClass instead of associative arrays (https://github.com/webonyx/graphql-php/issues/535) @@ -996,7 +996,7 @@ This release brings several breaking changes. Please refer to [UPGRADE](UPGRADE. ### Changed -- Compliant with the GraphQL specification [June 2018 Edition](https://spec.graphql.org/June2018/) +- Compliant with the GraphQL specification [June 2018 Edition](https://spec.graphql.org/June2018) - Having an empty string in `deprecationReason` will now print the `@deprecated` directive (only a `null` `deprecationReason` won't print the `@deprecated` directive). ### Optimized @@ -1172,19 +1172,19 @@ New features and notable changes: - Changed minimum PHP version from 5.4 to 5.5 - Lazy loading of types without separate build step (see https://github.com/webonyx/graphql-php/issues/69, see [docs](https://webonyx.github.io/graphql-php/type-system/schema/#lazy-loading-of-types)) - PSR-7 compliant Standard Server (see [docs](https://webonyx.github.io/graphql-php/executing-queries/#using-server)) -- New default error formatting, which does not expose sensitive data (see [docs](https://webonyx.github.io/graphql-php/error-handling/)) +- New default error formatting, which does not expose sensitive data (see [docs](https://webonyx.github.io/graphql-php/error-handling)) - Ability to define custom error handler to filter/log/re-throw exceptions after execution (see [docs](https://webonyx.github.io/graphql-php/error-handling/#custom-error-handling-and-formatting)) - Allow defining schema configuration using objects with fluent setters vs array (see [docs](https://webonyx.github.io/graphql-php/type-system/schema/#using-config-class)) - Allow serializing AST to array and re-creating AST from array lazily (see [docs](https://webonyx.github.io/graphql-php/reference/#graphqlutilsast)) - [Apollo-style](https://dev-blog.apollodata.com/query-batching-in-apollo-63acfd859862) query batching support via server (see [docs](https://webonyx.github.io/graphql-php/executing-queries/#query-batching)) - Schema validation, including validation of interface implementations (see [docs](https://webonyx.github.io/graphql-php/type-system/schema/#schema-validation)) -- Ability to pass custom config formatter when defining schema using [GraphQL type language](http://graphql.org/learn/schema/#type-language) (see [docs](https://webonyx.github.io/graphql-php/type-system/type-language/)) +- Ability to pass custom config formatter when defining schema using [GraphQL type language](http://graphql.org/learn/schema/#type-language) (see [docs](https://webonyx.github.io/graphql-php/type-system/type-language)) Improvements: - Significantly improved parser performance (see https://github.com/webonyx/graphql-php/issues/137 and https://github.com/webonyx/graphql-php/issues/128) - Support for PHP7 exceptions everywhere (see https://github.com/webonyx/graphql-php/issues/127) -- Improved [documentation](https://webonyx.github.io/graphql-php/) and docblock comments +- Improved [documentation](https://webonyx.github.io/graphql-php) and docblock comments Deprecations and breaking changes - see [UPGRADE](UPGRADE.md) document. diff --git a/docs/complementary-tools.md b/docs/complementary-tools.md index 493459e00..68a0456bc 100644 --- a/docs/complementary-tools.md +++ b/docs/complementary-tools.md @@ -1,6 +1,6 @@ ## Server Integrations -- [Standard Server](executing-queries.md#using-server) – Out of the box integration with any PSR-7 compatible framework (like [Slim](https://slimframework.com) or [Laminas Mezzio](https://docs.mezzio.dev/mezzio/)) +- [Standard Server](executing-queries.md#using-server) – Out of the box integration with any PSR-7 compatible framework (like [Slim](https://slimframework.com) or [Laminas Mezzio](https://docs.mezzio.dev/mezzio)) - [Lighthouse](https://github.com/nuwave/lighthouse) – Laravel package, SDL-first - [Laravel GraphQL](https://github.com/rebing/graphql-laravel) - Laravel package, code-first - [OverblogGraphQLBundle](https://github.com/overblog/GraphQLBundle) – Symfony bundle @@ -19,7 +19,7 @@ - [GraphQL Batch Processor](https://github.com/vasily-kartashov/graphql-batch-processing) – Provides a builder interface for defining collection, querying, filtering, and post-processing logic of batched data fetches - [GraphQL Utils](https://github.com/simPod/GraphQL-Utils) – Objective schema definition builders (no need for arrays anymore) - [Relay Library](https://github.com/ivome/graphql-relay-php) – Helps construct Relay related schema definitions -- [Resonance/GraphQL](https://resonance.distantmagic.com/docs/features/graphql/) – Integrates with Swoole for parallelism. Define your schema code-first with annotations. +- [Resonance/GraphQL](https://resonance.distantmagic.com/docs/features/graphql) – Integrates with Swoole for parallelism. Define your schema code-first with annotations. - [GraphQL PHP Validation Toolkit](https://github.com/shmax/graphql-php-validation-toolkit) - Small library for validation of user input - [MLL Scalars](https://github.com/mll-lab/graphql-php-scalars) - Collection of custom scalar types - [X GraphQL](https://github.com/x-graphql) - Provides set of libraries for building http schema, schema gateway, transforming schema, generating PHP code from execution definition, and more. diff --git a/docs/concepts.md b/docs/concepts.md index 18e4bc48b..e496c241b 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -147,6 +147,6 @@ $blogPostType = new ObjectType([ ## Further Reading -To get deeper understanding of GraphQL concepts - [read the docs on official GraphQL website](https://graphql.org/learn/) +To get deeper understanding of GraphQL concepts - [read the docs on official GraphQL website](https://graphql.org/learn) To get started with **graphql-php** - continue to next section ["Getting Started"](getting-started.md) diff --git a/docs/data-fetching.md b/docs/data-fetching.md index 09791e1cc..13fad0136 100644 --- a/docs/data-fetching.md +++ b/docs/data-fetching.md @@ -189,7 +189,7 @@ $queryType = new ObjectType([ Since: 0.9.0 One of the most annoying problems with data fetching is a so-called -[N+1 problem](https://secure.phabricator.com/book/phabcontrib/article/n_plus_one/).
+[N+1 problem](https://secure.phabricator.com/book/phabcontrib/article/n_plus_one).
Consider following GraphQL query: ```graphql @@ -262,7 +262,7 @@ If your project runs in an environment that supports async operations you can leverage the power of your platform to resolve some fields asynchronously. The only requirement: your platform must support the concept of Promises compatible with -[Promises A+](https://promisesaplus.com/) specification. +[Promises A+](https://promisesaplus.com) specification. To start using this feature, switch facade method for query execution from **executeQuery** to **promiseToExecute**: @@ -293,7 +293,7 @@ Where **$promiseAdapter** is an instance of: - For [AMPHP](https://github.com/amphp/amp):
`GraphQL\Executor\Promise\Adapter\AmpPromiseAdapter` -- For [Swoole](https://swoole.com/) or [OpenSwoole](https://openswoole.com/):
+- For [Swoole](https://swoole.com) or [OpenSwoole](https://openswoole.com):
You can use an external library: [Resonance](https://resonance.distantmagic.com/docs/features/graphql/standalone-promise-adapter.html) - Other platforms: write your own class implementing interface:
diff --git a/docs/executing-queries.md b/docs/executing-queries.md index 7c5ad5282..d4639c8fe 100644 --- a/docs/executing-queries.md +++ b/docs/executing-queries.md @@ -63,7 +63,7 @@ $server = new StandardServer([/* server options, see below */]); $server->handleRequest(); // parses PHP globals and emits response ``` -Server also supports [PSR-7 request/response interfaces](https://www.php-fig.org/psr/psr-7/): +Server also supports [PSR-7 request/response interfaces](https://www.php-fig.org/psr/psr-7): ```php use GraphQL\Server\StandardServer; @@ -92,7 +92,7 @@ PSR-7 is useful when you want to integrate the server into existing framework: - [PSR-7 for Laravel](https://laravel.com/docs/requests#psr7-requests) - [Symfony PSR-7 Bridge](https://symfony.com/doc/current/components/psr7.html) - [Slim](https://www.slimframework.com/docs/v4/concepts/value-objects.html) -- [Laminas Mezzio](https://docs.mezzio.dev/mezzio/) +- [Laminas Mezzio](https://docs.mezzio.dev/mezzio) ### Server configuration options @@ -103,7 +103,7 @@ PSR-7 is useful when you want to integrate the server into existing framework: | context | `mixed` | Any value that holds information shared between all field resolvers. Most often they use it to pass currently logged in user, locale details, etc.

It will be available as the 3rd argument in all field resolvers. (see section on [Field Definitions](type-definitions/object-types.md#field-configuration-options) for reference) **graphql-php** never modifies this value and passes it _as is_ to all underlying resolvers. | | fieldResolver | `callable` | A resolver function to use when one is not provided by the schema. If not provided, the [default field resolver is used](data-fetching.md#default-field-resolver). | | validationRules | `array` or `callable` | A set of rules for query validation step. The default value is all available rules. The empty array would allow skipping query validation (may be convenient for persisted queries which are validated before persisting and assumed valid during execution).

Pass `callable` to return different validation rules for different queries (e.g. empty array for persisted query and a full list of rules for regular queries). When passed, it is expected to have the following signature:

**function ([OperationParams](class-reference.md#graphqlserveroperationparams) $params, DocumentNode $node, $operationType): array** | -| queryBatching | `bool` | Flag indicating whether this server supports query batching ([apollo-style](https://www.apollographql.com/blog/apollo-client/performance/query-batching/)).

Defaults to **false** | +| queryBatching | `bool` | Flag indicating whether this server supports query batching ([apollo-style](https://www.apollographql.com/blog/apollo-client/performance/query-batching)).

Defaults to **false** | | debugFlag | `int` | Debug flags. See [docs on error debugging](error-handling.md#debugging-tools) (flag values are the same). | | persistedQueryLoader | `callable` | A function which is called to fetch actual query when server encounters a **queryId**.

The server does not implement persistence part (which you will have to build on your own), but it allows you to execute queries which were persisted previously.

Expected function signature:
**function ($queryId, [OperationParams](class-reference.md#graphqlserveroperationparams) $params)**

Function is expected to return query **string** or parsed **DocumentNode**

[Read more about persisted queries](https://www.apollographql.com/blog/apollo-client/persisted-graphql-queries). | | errorFormatter | `callable` | Custom error formatter. See [error handling docs](error-handling.md#custom-error-handling-and-formatting). | @@ -130,7 +130,7 @@ $server = new StandardServer($config); ## Query batching -Standard Server supports query batching ([apollo-style](https://www.apollographql.com/blog/apollo-client/performance/query-batching/)). +Standard Server supports query batching ([apollo-style](https://www.apollographql.com/blog/apollo-client/performance/query-batching)). One of the major benefits of Server over a sequence of **executeQuery()** calls is that [Deferred resolvers](data-fetching.md#solving-n1-problem) won't be isolated in queries. diff --git a/docs/getting-started.md b/docs/getting-started.md index 727594a6d..a7f687d86 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,7 +1,7 @@ ## Prerequisites This documentation assumes your familiarity with GraphQL concepts. If it is not the case - -first learn about GraphQL on [the official website](https://graphql.org/learn/). +first learn about GraphQL on [the official website](https://graphql.org/learn). ## Installation diff --git a/docs/index.md b/docs/index.md index 17bf323e5..726393fde 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,9 +13,9 @@ It is intended to be an alternative to REST and SOAP APIs (even for **existing a GraphQL itself is a [specification](https://github.com/graphql/graphql-spec) designed by Facebook engineers. Various implementations of this specification were written -[in different languages and environments](https://graphql.org/code/). +[in different languages and environments](https://graphql.org/code). -Great overview of GraphQL features and benefits is presented on [the official website](https://graphql.org/). +Great overview of GraphQL features and benefits is presented on [the official website](https://graphql.org). All of them equally apply to this PHP implementation. ## About graphql-php diff --git a/docs/security.md b/docs/security.md index dc9be4ce8..727b5caf7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -106,7 +106,7 @@ This will set the rule globally. Alternatively, you can provide validation rules ## Disabling Introspection -[Introspection](https://graphql.org/learn/introspection/) is a mechanism for fetching schema structure. +[Introspection](https://graphql.org/learn/introspection) is a mechanism for fetching schema structure. It is used by tools like GraphiQL for auto-completion, query validation, etc. Introspection is enabled by default. It means that anybody can get a full description of your schema by diff --git a/examples/04-async-php/amphp/http-server/README.md b/examples/04-async-php/amphp/http-server/README.md index bb531e2fd..702bd561b 100644 --- a/examples/04-async-php/amphp/http-server/README.md +++ b/examples/04-async-php/amphp/http-server/README.md @@ -1,6 +1,6 @@ ## HTTP-Server example with AMPHP -This is a basic example using [AMPHP](https://amphp.org/). +This is a basic example using [AMPHP](https://amphp.org). ### Dependencies diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index 5ca82abd5..a7328f617 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -154,7 +154,7 @@ public static function deprecatedDirective(): Directive 'args' => [ self::REASON_ARGUMENT_NAME => [ 'type' => Type::string(), - 'description' => 'Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org/).', + 'description' => 'Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org).', 'defaultValue' => self::DEFAULT_DEPRECATION_REASON, ], ], diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index 462f2d5ed..738597908 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -1091,7 +1091,7 @@ public function testPrintIntrospectionSchema(): void "Marks an element of a GraphQL schema as no longer supported." directive @deprecated( - "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org/)." + "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org)." reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION From c90372722fcb28468235faee827a4290387ca73b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:47:32 +0000 Subject: [PATCH 09/14] Autofix --- docs/executing-queries.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/executing-queries.md b/docs/executing-queries.md index d4639c8fe..7956bbc3b 100644 --- a/docs/executing-queries.md +++ b/docs/executing-queries.md @@ -103,7 +103,7 @@ PSR-7 is useful when you want to integrate the server into existing framework: | context | `mixed` | Any value that holds information shared between all field resolvers. Most often they use it to pass currently logged in user, locale details, etc.

It will be available as the 3rd argument in all field resolvers. (see section on [Field Definitions](type-definitions/object-types.md#field-configuration-options) for reference) **graphql-php** never modifies this value and passes it _as is_ to all underlying resolvers. | | fieldResolver | `callable` | A resolver function to use when one is not provided by the schema. If not provided, the [default field resolver is used](data-fetching.md#default-field-resolver). | | validationRules | `array` or `callable` | A set of rules for query validation step. The default value is all available rules. The empty array would allow skipping query validation (may be convenient for persisted queries which are validated before persisting and assumed valid during execution).

Pass `callable` to return different validation rules for different queries (e.g. empty array for persisted query and a full list of rules for regular queries). When passed, it is expected to have the following signature:

**function ([OperationParams](class-reference.md#graphqlserveroperationparams) $params, DocumentNode $node, $operationType): array** | -| queryBatching | `bool` | Flag indicating whether this server supports query batching ([apollo-style](https://www.apollographql.com/blog/apollo-client/performance/query-batching)).

Defaults to **false** | +| queryBatching | `bool` | Flag indicating whether this server supports query batching ([apollo-style](https://www.apollographql.com/blog/apollo-client/performance/query-batching)).

Defaults to **false** | | debugFlag | `int` | Debug flags. See [docs on error debugging](error-handling.md#debugging-tools) (flag values are the same). | | persistedQueryLoader | `callable` | A function which is called to fetch actual query when server encounters a **queryId**.

The server does not implement persistence part (which you will have to build on your own), but it allows you to execute queries which were persisted previously.

Expected function signature:
**function ($queryId, [OperationParams](class-reference.md#graphqlserveroperationparams) $params)**

Function is expected to return query **string** or parsed **DocumentNode**

[Read more about persisted queries](https://www.apollographql.com/blog/apollo-client/persisted-graphql-queries). | | errorFormatter | `callable` | Custom error formatter. See [error handling docs](error-handling.md#custom-error-handling-and-formatting). | From 7342712d4c584c27e64768243c087aa4e07f75ea Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 14 Jun 2026 16:53:49 +0200 Subject: [PATCH 10/14] Fix @see annotations to match graphql-js test names exactly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .../SingleFieldSubscriptionsTest.php | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/tests/Validator/SingleFieldSubscriptionsTest.php b/tests/Validator/SingleFieldSubscriptionsTest.php index 075cc2426..8fa7f9869 100644 --- a/tests/Validator/SingleFieldSubscriptionsTest.php +++ b/tests/Validator/SingleFieldSubscriptionsTest.php @@ -14,7 +14,7 @@ */ final class SingleFieldSubscriptionsTest extends ValidatorTestCase { - /** @see it('valid single field subscription') */ + /** @see it('valid subscription') */ public function testValidSingleFieldSubscription(): void { $this->expectPassesRule( @@ -29,7 +29,6 @@ public function testValidSingleFieldSubscription(): void ); } - /** @see it('valid single field bulk subscriptions') */ public function testValidSingleFieldBulkSubscriptions(): void { $this->expectPassesRule( @@ -50,7 +49,6 @@ public function testValidSingleFieldBulkSubscriptions(): void ); } - /** @see it('valid single field anonymous subscription') */ public function testValidSingleFieldAnonymousSubscription(): void { $this->expectPassesRule( @@ -65,7 +63,6 @@ public function testValidSingleFieldAnonymousSubscription(): void ); } - /** @see it('valid single field subscription') */ public function testValidSingleFieldSubscriptionWithMultipleResultFields(): void { $this->expectPassesRule( @@ -120,7 +117,6 @@ public function testValidSubscriptionWithFragmentAndField(): void ); } - /** @see it('valid subscription with inline fragment') */ public function testValidSubscriptionWithInlineFragment(): void { $this->expectPassesRule( @@ -137,7 +133,7 @@ public function testValidSubscriptionWithInlineFragment(): void ); } - /** @see it('invalid multiple field subscription') */ + /** @see it('fails with more than one root field') */ public function testInvalidMultipleFieldSubscription(): void { $this->expectFailsRule( @@ -156,7 +152,7 @@ public function testInvalidMultipleFieldSubscription(): void ); } - /** @see it('invalid multiple field anonymous subscription') */ + /** @see it('fails with more than one root field in anonymous subscriptions') */ public function testInvalidMultipleFieldAnonymousSubscription(): void { $this->expectFailsRule( @@ -175,7 +171,7 @@ public function testInvalidMultipleFieldAnonymousSubscription(): void ); } - /** @see it('invalid many fields subscription') */ + /** @see it('fails with many more than one root field') */ public function testInvalidManyFieldsSubscription(): void { $this->expectFailsRule( @@ -197,7 +193,6 @@ public function testInvalidManyFieldsSubscription(): void ); } - /** @see it('invalid many fields anonymous subscription') */ public function testInvalidManyFieldAnonymousSubscription(): void { $this->expectFailsRule( @@ -285,7 +280,7 @@ public function testFailsWithIntrospectionField(): void ); } - /** @see it('fails with introspection field anonymous') */ + /** @see it('fails with introspection field in anonymous subscription') */ public function testFailsWithIntrospectionFieldAnonymous(): void { $this->expectFailsRule( @@ -342,7 +337,7 @@ public function testFailsWithAliasedIntrospectionViaFragment(): void ); } - /** @see it('fails with @skip directive') */ + /** @see it('fails with @skip or @include directive') */ public function testFailsWithSkipDirective(): void { $this->expectFailsRule( @@ -358,7 +353,6 @@ public function testFailsWithSkipDirective(): void ); } - /** @see it('fails with @include directive') */ public function testFailsWithIncludeDirective(): void { $this->expectFailsRule( @@ -374,7 +368,7 @@ public function testFailsWithIncludeDirective(): void ); } - /** @see it('fails with @skip directive anonymous') */ + /** @see it('fails with @skip or @include directive in anonymous subscription') */ public function testFailsWithSkipDirectiveAnonymous(): void { $this->expectFailsRule( @@ -390,7 +384,6 @@ public function testFailsWithSkipDirectiveAnonymous(): void ); } - /** @see it('valid subscription with inline fragment without type condition') */ public function testValidSubscriptionWithInlineFragmentWithoutTypeCondition(): void { $this->expectPassesRule( @@ -407,7 +400,6 @@ public function testValidSubscriptionWithInlineFragmentWithoutTypeCondition(): v ); } - /** @see it('ignores fields from non-matching fragment type condition') */ public function testIgnoresFieldsFromNonMatchingFragmentTypeCondition(): void { $this->expectPassesRule( From d6dd2738fcb7cf29dec689f6ee09211d23331d9d Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 14 Jun 2026 16:59:00 +0200 Subject: [PATCH 11/14] Document @see it(...) convention for graphql-js test references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .ai/AGENTS.md | 1 + CONTRIBUTING.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.ai/AGENTS.md b/.ai/AGENTS.md index ba6598fcd..61b146186 100644 --- a/.ai/AGENTS.md +++ b/.ai/AGENTS.md @@ -36,6 +36,7 @@ The following elements are part of the stable public API: - Keep changes focused and minimal. - Run `make stan` and `make test` for behavioral changes. - Use `make fix` for style/refactoring consistency when needed. +- Only add `/** @see it('...') */` to a test when it has a direct counterpart with that exact name in graphql-js. ## Release Workflow diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7fc32305..bafe39055 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,8 @@ composer test ``` Some tests have an annotation such as `@see it('')`. -It references a matching test in the [graphql-js implementation](https://github.com/graphql/graphql-js). +It references a test with the exact same name in the [graphql-js reference implementation](https://github.com/graphql/graphql-js). +Only add `@see it(...)` when the test has a direct counterpart in graphql-js with that exact name. When porting tests that utilize [the `dedent()` test utility from `graphql-js`](https://github.com/graphql/graphql-js/blob/99d6079434/src/__testUtils__/dedent.js), we instead use [the PHP native `nowdoc` syntax](https://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.nowdoc). From 76af9e18f91cdebcbbae3b73912d6d10e8d88661 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 14 Jun 2026 17:00:23 +0200 Subject: [PATCH 12/14] Revert trailing slash removal from Directive.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commonmark.org URL in the @deprecated directive description must match graphql-js exactly since it's part of the introspection schema. 🤖 Generated with Claude Code --- src/Type/Definition/Directive.php | 2 +- tests/Utils/SchemaPrinterTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index a7328f617..5ca82abd5 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -154,7 +154,7 @@ public static function deprecatedDirective(): Directive 'args' => [ self::REASON_ARGUMENT_NAME => [ 'type' => Type::string(), - 'description' => 'Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org).', + 'description' => 'Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org/).', 'defaultValue' => self::DEFAULT_DEPRECATION_REASON, ], ], diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index 738597908..462f2d5ed 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -1091,7 +1091,7 @@ public function testPrintIntrospectionSchema(): void "Marks an element of a GraphQL schema as no longer supported." directive @deprecated( - "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org)." + "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org/)." reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION From 4bed2643df15a89bf0eb636168c91a4e82f7ebaa Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 14 Jun 2026 17:28:21 +0200 Subject: [PATCH 13/14] Document spec compliance status in README and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit October 2021 is fully compliant, September 2025 tracked in #1931. 🤖 Generated with Claude Code --- README.md | 3 +++ docs/index.md | 6 ++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 582ed5e39..bb9aa103a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ This is a PHP implementation of the [GraphQL](https://graphql.org) [specification](https://github.com/graphql/graphql-spec) based on the [reference implementation in JavaScript](https://github.com/graphql/graphql-js). +Fully compliant with the [October 2021 GraphQL specification](https://spec.graphql.org/October2021). +[September 2025 specification](https://spec.graphql.org/September2025) compliance is [in progress](https://github.com/webonyx/graphql-php/issues/1931). + ## Sponsors If you make money using this project, please consider sponsoring [its maintainer on GitHub Sponsors](https://github.com/sponsors/spawnia) or [the project on OpenCollective](https://opencollective.com/webonyx-graphql-php). diff --git a/docs/index.md b/docs/index.md index 726393fde..f27e20f28 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,10 +46,8 @@ existing PHP frameworks, add support for Relay, etc. The first version of this library (v0.1) was released on August 10th 2015. -The current version supports all features described by GraphQL specification -as well as some experimental features like -[schema definition language](schema-definition-language.md) and -[schema printer](class-reference.md#graphqlutilsschemaprinter). +Fully compliant with the [October 2021 GraphQL specification](https://spec.graphql.org/October2021). +[September 2025 specification](https://spec.graphql.org/September2025) compliance is [in progress](https://github.com/webonyx/graphql-php/issues/1931). Ready for real-world usage. From 60dcb2f5e1514d9c95017b353f4ea5efe34f112a Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 17 Jun 2026 07:47:20 +0200 Subject: [PATCH 14/14] Check @skip/@include directives on fragment-contributed root fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directive check was only applied to the operation's immediate top-level selections, missing directives on fields contributed via fragment spreads or inline fragments. Move the check into collectFields() so every root-level FieldNode is validated regardless of how it was contributed. https://github.com/webonyx/graphql-php/pull/1930#discussion_r3409843948 🤖 Generated with Claude Code --- .../Rules/SingleFieldSubscription.php | 74 +++++++------------ .../SingleFieldSubscriptionsTest.php | 18 +++++ 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/Validator/Rules/SingleFieldSubscription.php b/src/Validator/Rules/SingleFieldSubscription.php index 1b0c7f1c1..ca2e2deee 100644 --- a/src/Validator/Rules/SingleFieldSubscription.php +++ b/src/Validator/Rules/SingleFieldSubscription.php @@ -10,9 +10,7 @@ use GraphQL\Language\AST\InlineFragmentNode; use GraphQL\Language\AST\Node; use GraphQL\Language\AST\NodeKind; -use GraphQL\Language\AST\NodeList; use GraphQL\Language\AST\OperationDefinitionNode; -use GraphQL\Language\AST\SelectionNode; use GraphQL\Language\AST\SelectionSetNode; use GraphQL\Language\Visitor; use GraphQL\Language\VisitorOperation; @@ -44,42 +42,32 @@ public function getVisitor(QueryValidationContext $context): array } } - // Check for @skip/@include on top-level selections - /** @var array $forbiddenDirectiveNodes */ - $forbiddenDirectiveNodes = []; - foreach ($node->selectionSet->selections as $selection) { - $directives = self::getDirectives($selection); - foreach ($directives as $directive) { - $directiveName = $directive->name->value; - if ($directiveName === Directive::SKIP_NAME || $directiveName === Directive::INCLUDE_NAME) { - $forbiddenDirectiveNodes[] = $directive; - } - } - } - - if ($forbiddenDirectiveNodes !== []) { - $context->reportError(new Error( - static::skipIncludeInOperation($operationName), - $forbiddenDirectiveNodes - )); - - return Visitor::skipNode(); - } - - // Collect fields by expanding fragments + // Collect fields by expanding fragments, also collecting forbidden directives /** @var array> $groupedFieldSet */ $groupedFieldSet = []; /** @var array $visitedFragments */ $visitedFragments = []; + /** @var array $forbiddenDirectiveNodes */ + $forbiddenDirectiveNodes = []; self::collectFields( $schema, $subscriptionType, $node->selectionSet, $fragments, $groupedFieldSet, - $visitedFragments + $visitedFragments, + $forbiddenDirectiveNodes ); + if ($forbiddenDirectiveNodes !== []) { + $context->reportError(new Error( + static::skipIncludeInOperation($operationName), + $forbiddenDirectiveNodes + )); + + return Visitor::skipNode(); + } + if (count($groupedFieldSet) > 1) { $keys = array_keys($groupedFieldSet); /** @var array $extraFieldNodes */ @@ -114,27 +102,11 @@ public function getVisitor(QueryValidationContext $context): array ]; } - /** - * @param SelectionNode&Node $selection - * - * @return NodeList - */ - private static function getDirectives($selection): NodeList - { - if ($selection instanceof FieldNode - || $selection instanceof FragmentSpreadNode - || $selection instanceof InlineFragmentNode - ) { - return $selection->directives; - } - - return new NodeList([]); - } - /** * @param array $fragments * @param array> $groupedFieldSet * @param array $visitedFragments + * @param array $forbiddenDirectiveNodes * * @throws \Exception */ @@ -144,10 +116,18 @@ private static function collectFields( SelectionSetNode $selectionSet, array $fragments, array &$groupedFieldSet, - array &$visitedFragments + array &$visitedFragments, + array &$forbiddenDirectiveNodes ): void { foreach ($selectionSet->selections as $selection) { if ($selection instanceof FieldNode) { + foreach ($selection->directives as $directive) { + $directiveName = $directive->name->value; + if ($directiveName === Directive::SKIP_NAME || $directiveName === Directive::INCLUDE_NAME) { + $forbiddenDirectiveNodes[] = $directive; + } + } + $responseKey = $selection->alias->value ?? $selection->name->value; if (! isset($groupedFieldSet[$responseKey])) { $groupedFieldSet[$responseKey] = []; @@ -165,7 +145,8 @@ private static function collectFields( $selection->selectionSet, $fragments, $groupedFieldSet, - $visitedFragments + $visitedFragments, + $forbiddenDirectiveNodes ); } elseif ($selection instanceof FragmentSpreadNode) { $fragmentName = $selection->name->value; @@ -190,7 +171,8 @@ private static function collectFields( $fragment->selectionSet, $fragments, $groupedFieldSet, - $visitedFragments + $visitedFragments, + $forbiddenDirectiveNodes ); } } diff --git a/tests/Validator/SingleFieldSubscriptionsTest.php b/tests/Validator/SingleFieldSubscriptionsTest.php index 8fa7f9869..72a5bc8da 100644 --- a/tests/Validator/SingleFieldSubscriptionsTest.php +++ b/tests/Validator/SingleFieldSubscriptionsTest.php @@ -384,6 +384,24 @@ public function testFailsWithSkipDirectiveAnonymous(): void ); } + public function testFailsWithSkipDirectiveViaFragment(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + ...frag + } + fragment frag on SubscriptionRoot { + catSubscribe @include(if: true) { + meows + } + } + ', + [$this->skipIncludeInOperation('sub', [6, 22])] + ); + } + public function testValidSubscriptionWithInlineFragmentWithoutTypeCondition(): void { $this->expectPassesRule(