Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
aee90de
Add @specifiedBy directive support, address review comments
Copilot May 13, 2026
1f67690
Enable testExtendsDifferentTypesMultipleTimes with specifiedBy directive
Copilot May 13, 2026
b47914d
Fix directiveExcludesField and tighten getSpecifiedByURL parameter type
Copilot May 13, 2026
507db56
Remove unnecessary @deprecated and @specifiedBy guards from directive…
Copilot May 13, 2026
ccb5788
Fix directive ordering and trailing newline in test heredoc
Copilot May 13, 2026
b23ea61
Fix directive ordering in BuildSchema.php: append oneOf before specif…
Copilot May 13, 2026
80b3804
Align @specifiedBy with graphql-js: ordering (specifiedBy before oneO…
Copilot May 13, 2026
e11c9a9
Add changelog entry and docs for @specifiedBy directive support
Copilot May 13, 2026
9f2f581
Re-fix description in scalars doc to say behavior instead of serializ…
Copilot May 13, 2026
22c0ab1
Address review comments: introspection specifiedByURL, custom directi…
Copilot May 13, 2026
b9f6ff7
Fix code style issues found by autofix.ci: import ordering and docblo…
Copilot May 13, 2026
d615104
Address review comments: lazy variable coercion, extension node @spec…
Copilot May 14, 2026
e3b7d7f
Add tests for @specifiedBy extension nodes, introspection specifiedBy…
Copilot May 14, 2026
033fc1b
Fix review comments on tests: improve comment clarity and remove unne…
Copilot May 14, 2026
9d39aca
Autofix
autofix-ci[bot] May 14, 2026
97268c1
Use proper imports in BuildClientSchemaTest instead of FQCNs
Copilot May 14, 2026
e9457cd
Align tests with graphql-js: @see annotations, missing tests, fix bro…
Copilot May 14, 2026
b864241
Address latest 3 review comments: ScalarType subclass default, docs g…
Copilot May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co

## Unreleased

### Added

- Add `@specifiedBy` built-in directive and `ScalarType::$specifiedByURL` property https://github.com/webonyx/graphql-php/pull/1913

## v15.32.3

### Fixed
Expand Down
5 changes: 4 additions & 1 deletion docs/type-definitions/directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ The directive is a way for a client to give GraphQL server additional context an
the query. The directive can be attached to a field or fragment and can affect the execution of the
query in any way the server desires.

GraphQL specification includes two built-in directives:
The GraphQL specification includes the following built-in directives:

- **@include(if: Boolean)** Only include this field or fragment in the result if the argument is **true**
- **@skip(if: Boolean)** Skip this field or fragment if the argument is **true**
- **@deprecated(reason: String)** Marks a field, argument, input field, or enum value as deprecated with an optional reason
- **@specifiedBy(url: String!)** Links a custom scalar type to a human-readable specification URL
- **@oneOf** Marks an input object type as a "oneof" input, requiring exactly one field to be non-null

For example:

Expand Down
29 changes: 29 additions & 0 deletions docs/type-definitions/scalars.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,35 @@ $emailType = new CustomScalarType([
Keep in mind the passed functions will be called statically, so a passed in `callable`
such as `[Foo::class, 'bar']` should only reference static class methods.

## Linking a Scalar to Its Specification

The `@specifiedBy` built-in directive lets you attach a URL to a custom scalar that specifies the behavior of that scalar.
This URL is exposed through introspection and printed in SDL output.

Pass `specifiedByURL` when constructing the type:

```php
use GraphQL\Type\Definition\CustomScalarType;

$uuidType = new CustomScalarType([
'name' => 'UUID',
'specifiedByURL' => 'https://tools.ietf.org/html/rfc4122',
'serialize' => static function ($value) { /* ... */ },
'parseValue' => static function ($value) { /* ... */ },
'parseLiteral' => static function ($valueNode, ?array $variables = null) { /* ... */ },
]);

$uuidType->specifiedByURL; // "https://tools.ietf.org/html/rfc4122"
```

When building a schema from SDL, `@specifiedBy` is parsed automatically:

```graphql
scalar UUID @specifiedBy(url: "https://tools.ietf.org/html/rfc4122")
```

And `SchemaPrinter` will emit the directive when printing a schema that contains a scalar with a `specifiedByURL`.

## Overriding Built-in Scalars

You can override built-in scalars (`String`, `Int`, `Float`, `Boolean`, `ID`) on a per-schema basis by passing a custom scalar with the same name through the `types` option.
Expand Down
3 changes: 2 additions & 1 deletion src/Language/Printer.php
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,8 @@ protected static function p(?Node $node): string
return BlockString::print($node->value);
}

return json_encode($node->value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
// Do not escape unicode or slashes to keep the output readable
return json_encode($node->value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

case $node instanceof UnionTypeDefinitionNode:
$typesStr = static::printList($node->types, ' | ');
Expand Down
2 changes: 2 additions & 0 deletions src/Type/Definition/CustomScalarType.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* serialize?: callable(mixed): mixed,
* parseValue: callable(mixed): mixed,
* parseLiteral: callable(ValueNode&Node, array<string, mixed>|null): mixed,
* specifiedByURL?: string|null,
* astNode?: ScalarTypeDefinitionNode|null,
* extensionASTNodes?: array<ScalarTypeExtensionNode>|null
* }
Expand All @@ -27,6 +28,7 @@
* serialize: callable(mixed): mixed,
* parseValue?: callable(mixed): mixed,
* parseLiteral?: callable(ValueNode&Node, array<string, mixed>|null): mixed,
* specifiedByURL?: string|null,
* astNode?: ScalarTypeDefinitionNode|null,
* extensionASTNodes?: array<ScalarTypeExtensionNode>|null
* }
Expand Down
25 changes: 24 additions & 1 deletion src/Type/Definition/Directive.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@ class Directive
public const DEFAULT_DEPRECATION_REASON = 'No longer supported';

public const INCLUDE_NAME = 'include';
public const IF_ARGUMENT_NAME = 'if';
public const SKIP_NAME = 'skip';
public const IF_ARGUMENT_NAME = 'if';

public const DEPRECATED_NAME = 'deprecated';
public const REASON_ARGUMENT_NAME = 'reason';

public const SPECIFIED_BY_NAME = 'specifiedBy';
public const URL_ARGUMENT_NAME = 'url';

public const ONE_OF_NAME = 'oneOf';

/**
Expand Down Expand Up @@ -82,6 +87,7 @@ public static function builtInDirectives(): array
self::INCLUDE_NAME => self::includeDirective(),
self::SKIP_NAME => self::skipDirective(),
self::DEPRECATED_NAME => self::deprecatedDirective(),
self::SPECIFIED_BY_NAME => self::specifiedByDirective(),
self::ONE_OF_NAME => self::oneOfDirective(),
];
}
Expand Down Expand Up @@ -167,6 +173,23 @@ public static function oneOfDirective(): Directive
]);
}

public static function specifiedByDirective(): Directive
{
return self::$internalDirectives[self::SPECIFIED_BY_NAME] ??= new self([
'name' => self::SPECIFIED_BY_NAME,
'description' => 'Exposes a URL that specifies the behavior of this scalar.',
'locations' => [
DirectiveLocation::SCALAR,
],
'args' => [
self::URL_ARGUMENT_NAME => [
'type' => Type::nonNull(Type::string()),
'description' => 'The URL that specifies the behavior of this scalar.',
],
],
]);
}

public static function isBuiltInDirective(self $directive): bool
{
return array_key_exists($directive->name, self::builtInDirectives());
Expand Down
4 changes: 4 additions & 0 deletions src/Type/Definition/ScalarType.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
* @phpstan-type ScalarConfig array{
* name?: string|null,
* description?: string|null,
* specifiedByURL?: string|null,
* astNode?: ScalarTypeDefinitionNode|null,
* extensionASTNodes?: array<ScalarTypeExtensionNode>|null
* }
Expand All @@ -38,6 +39,8 @@ abstract class ScalarType extends Type implements OutputType, InputType, LeafTyp

public ?ScalarTypeDefinitionNode $astNode;

public ?string $specifiedByURL;

/** @var array<ScalarTypeExtensionNode> */
public array $extensionASTNodes;

Expand All @@ -53,6 +56,7 @@ public function __construct(array $config = [])
{
$this->name = $config['name'] ?? $this->inferName();
$this->description = $config['description'] ?? $this->description ?? null;
$this->specifiedByURL = $config['specifiedByURL'] ?? $this->specifiedByURL ?? null;
$this->astNode = $config['astNode'] ?? null;
$this->extensionASTNodes = $config['extensionASTNodes'] ?? [];

Expand Down
19 changes: 19 additions & 0 deletions src/Type/Introspection.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
* descriptions?: bool,
* directiveIsRepeatable?: bool,
* schemaDescription?: bool,
* specifiedByURL?: bool,
* typeIsOneOf?: bool,
* }
*
Expand All @@ -41,6 +42,12 @@
* - directiveIsRepeatable
* Include field `isRepeatable` for directives?
* Default: false
* - schemaDescription
* Include `description` on the schema?
* Default: false
* - specifiedByURL
* Include field `specifiedByURL` for scalar types?
* Default: false
* - typeIsOneOf
* Include field `isOneOf` for types?
* Default: false
Expand Down Expand Up @@ -87,6 +94,7 @@ public static function getIntrospectionQuery(array $options = []): string
'descriptions' => true,
'directiveIsRepeatable' => false,
'schemaDescription' => false,
'specifiedByURL' => false,
'typeIsOneOf' => false,
], $options);

Expand All @@ -99,6 +107,9 @@ public static function getIntrospectionQuery(array $options = []): string
$schemaDescription = $optionsWithDefaults['schemaDescription']
? $descriptions
: '';
$specifiedByURL = $optionsWithDefaults['specifiedByURL']
? 'specifiedByURL'
: '';
$typeIsOneOf = $optionsWithDefaults['typeIsOneOf']
? 'isOneOf'
: '';
Expand Down Expand Up @@ -129,6 +140,7 @@ public static function getIntrospectionQuery(array $options = []): string
kind
name
{$descriptions}
{$specifiedByURL}
{$typeIsOneOf}
fields(includeDeprecated: true) {
name
Expand Down Expand Up @@ -227,6 +239,7 @@ public static function fromSchema(Schema $schema, array $options = []): array
$optionsWithDefaults = array_merge([
'directiveIsRepeatable' => true,
'schemaDescription' => true,
'specifiedByURL' => true,
'typeIsOneOf' => true,
], $options);

Expand Down Expand Up @@ -361,6 +374,12 @@ public static function _type(): ObjectType
? $type->description
: null,
],
'specifiedByURL' => [
'type' => Type::string(),
'resolve' => static fn (Type $type): ?string => $type instanceof ScalarType
? $type->specifiedByURL
: null,
],
Comment on lines +377 to +382
'fields' => [
'type' => Type::listOf(Type::nonNull(self::_field())),
'args' => [
Expand Down
30 changes: 29 additions & 1 deletion src/Utils/ASTDefinitionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use GraphQL\Language\AST\ObjectTypeExtensionNode;
use GraphQL\Language\AST\ScalarTypeDefinitionNode;
use GraphQL\Language\AST\ScalarTypeExtensionNode;
use GraphQL\Language\AST\StringValueNode;
use GraphQL\Language\AST\TypeDefinitionNode;
use GraphQL\Language\AST\TypeExtensionNode;
use GraphQL\Language\AST\TypeNode;
Expand Down Expand Up @@ -403,7 +404,6 @@ public function buildField(FieldDefinitionNode $field, object $node): array
* @param EnumValueDefinitionNode|FieldDefinitionNode|InputValueDefinitionNode $node
*
* @throws \Exception
* @throws \ReflectionException
* @throws InvariantViolation
*/
private function getDeprecationReason(Node $node): ?string
Expand All @@ -416,6 +416,33 @@ private function getDeprecationReason(Node $node): ?string
return $deprecated['reason'] ?? null;
}

/**
* Returns the specifiedBy URL from a scalar type's definition and extension directives,
* reading directly from the AST to safely handle custom @specifiedBy directive definitions
* with different argument shapes.
*
* @param array<ScalarTypeExtensionNode> $extensionNodes
*/
private function getSpecifiedByURL(ScalarTypeDefinitionNode $def, array $extensionNodes = []): ?string
{
foreach ([$def, ...$extensionNodes] as $node) {
foreach ($node->directives as $directive) {
if ($directive->name->value !== Directive::SPECIFIED_BY_NAME) {
continue;
}

foreach ($directive->arguments as $argument) {
if ($argument->name->value === Directive::URL_ARGUMENT_NAME
&& $argument->value instanceof StringValueNode) {
return $argument->value->value;
}
}
}
}

return null;
}

/**
* @param array<ObjectTypeDefinitionNode|ObjectTypeExtensionNode|InterfaceTypeDefinitionNode|InterfaceTypeExtensionNode> $nodes
*
Expand Down Expand Up @@ -533,6 +560,7 @@ private function makeScalarDef(ScalarTypeDefinitionNode $def): CustomScalarType
'serialize' => static fn ($value) => $value,
'astNode' => $def,
'extensionASTNodes' => $extensionASTNodes,
'specifiedByURL' => $this->getSpecifiedByURL($def, $extensionASTNodes),
]);
}

Expand Down
3 changes: 2 additions & 1 deletion src/Utils/BuildClientSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ private function buildType(array $type): NamedType
}

/**
* @param array<string, string> $scalar
* @param array<string, string|null> $scalar
*
* @throws InvariantViolation
*/
Expand All @@ -323,6 +323,7 @@ private function buildScalarDef(array $scalar): ScalarType
return new CustomScalarType([
'name' => $scalar['name'],
'description' => $scalar['description'],
'specifiedByURL' => $scalar['specifiedByURL'] ?? null,
'serialize' => static fn ($value) => $value,
]);
}
Expand Down
3 changes: 3 additions & 0 deletions src/Utils/BuildSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@ static function (string $typeName): Type {
if (! isset($directivesByName['deprecated'])) {
$directives[] = Directive::deprecatedDirective();
}
if (! isset($directivesByName['specifiedBy'])) {
$directives[] = Directive::specifiedByDirective();
}
if (! isset($directivesByName['oneOf'])) {
$directives[] = Directive::oneOfDirective();
}
Expand Down
21 changes: 21 additions & 0 deletions src/Utils/SchemaExtender.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use GraphQL\Language\AST\ScalarTypeExtensionNode;
use GraphQL\Language\AST\SchemaDefinitionNode;
use GraphQL\Language\AST\SchemaExtensionNode;
use GraphQL\Language\AST\StringValueNode;
use GraphQL\Language\AST\TypeDefinitionNode;
use GraphQL\Language\AST\TypeExtensionNode;
use GraphQL\Language\AST\UnionTypeExtensionNode;
Expand Down Expand Up @@ -226,12 +227,32 @@ protected function extendScalarType(ScalarType $type): CustomScalarType
/** @var array<ScalarTypeExtensionNode> $extensionASTNodes */
$extensionASTNodes = $this->extensionASTNodes($type);

$specifiedByURL = $type->specifiedByURL;
if ($specifiedByURL === null) {
foreach ($extensionASTNodes as $extensionNode) {
foreach ($extensionNode->directives as $directive) {
if ($directive->name->value !== Directive::SPECIFIED_BY_NAME) {
continue;
}

foreach ($directive->arguments as $argument) {
if ($argument->name->value === Directive::URL_ARGUMENT_NAME
&& $argument->value instanceof StringValueNode) {
$specifiedByURL = $argument->value->value;
break 3;
}
}
}
}
}

return new CustomScalarType([
'name' => $type->name,
'description' => $type->description,
'serialize' => [$type, 'serialize'],
'parseValue' => [$type, 'parseValue'],
'parseLiteral' => [$type, 'parseLiteral'],
'specifiedByURL' => $specifiedByURL,
'astNode' => $type->astNode,
'extensionASTNodes' => $extensionASTNodes,
]);
Expand Down
25 changes: 24 additions & 1 deletion src/Utils/SchemaPrinter.php
Original file line number Diff line number Diff line change
Expand Up @@ -364,11 +364,14 @@ protected static function printInputValue($arg): string
* @phpstan-param Options $options
*
* @throws \JsonException
* @throws InvariantViolation
* @throws SerializationError
*/
protected static function printScalar(ScalarType $type, array $options): string
{
return static::printDescription($options, $type)
. "scalar {$type->name}";
. "scalar {$type->name}"
. static::printSpecifiedBy($type);
}

/**
Expand Down Expand Up @@ -455,6 +458,26 @@ protected static function printDeprecated($deprecation): string
return " @deprecated(reason: {$reasonASTString})";
}

/**
* @throws \JsonException
* @throws InvariantViolation
* @throws SerializationError
*/
protected static function printSpecifiedBy(ScalarType $type): string
{
$url = $type->specifiedByURL;
if ($url === null) {
return '';
}

$urlAST = AST::astFromValue($url, Type::string());
assert($urlAST instanceof StringValueNode);

$urlASTString = Printer::doPrint($urlAST);

return " @specifiedBy(url: {$urlASTString})";
}

protected static function printImplementedInterfaces(ImplementingType $type): string
{
$interfaces = $type->getInterfaces();
Expand Down
Loading
Loading