From ae94d581b56f0617bb5f800a6c9261abb9d18c6e Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 17 Mar 2026 10:10:31 +0100 Subject: [PATCH 1/6] Deprecate throwing type loader for built-in scalars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeLoaders that throw for unknown types (e.g. Magento) broke after built-in scalar probing was added for per-schema overrides. Catch exceptions from typeLoader calls for built-in scalar names, trigger E_USER_DEPRECATED, and fall back to the default built-in scalars. Non-built-in types still propagate exceptions as before. Fixes https://github.com/webonyx/graphql-php/issues/1874 🤖 Generated with Claude Code --- src/Type/Schema.php | 27 +++++++++++++-- tests/Type/TypeLoaderTestCaseBase.php | 50 +++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/Type/Schema.php b/src/Type/Schema.php index 8708552b4..83137fd81 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -194,7 +194,16 @@ public function getTypeMap(): array continue; } - $type = ($this->config->typeLoader)($scalarName); + try { + $type = ($this->config->typeLoader)($scalarName); + } catch (\Throwable $e) { + @trigger_error( + "Type loader should return null for unknown types, but threw for built-in scalar \"{$scalarName}\": {$e->getMessage()}. In a future major version, this exception will not be caught.", + \E_USER_DEPRECATED, + ); + continue; + } + if ($type instanceof ScalarType && $type->name === $scalarName && $type !== $builtInScalars[$scalarName] @@ -366,7 +375,21 @@ private function loadType(string $typeName): ?Type return $this->getTypeMap()[$typeName] ?? null; } - $type = ($this->config->typeLoader)($typeName); + try { + $type = ($this->config->typeLoader)($typeName); + } catch (\Throwable $e) { + if (isset(Type::builtInScalars()[$typeName])) { + @trigger_error( + "Type loader should return null for unknown types, but threw for built-in scalar \"{$typeName}\": {$e->getMessage()}. In a future major version, this exception will not be caught.", + \E_USER_DEPRECATED, + ); + + return null; + } + + throw $e; + } + if ($type === null) { return null; } diff --git a/tests/Type/TypeLoaderTestCaseBase.php b/tests/Type/TypeLoaderTestCaseBase.php index ae965cab3..da96dbc54 100644 --- a/tests/Type/TypeLoaderTestCaseBase.php +++ b/tests/Type/TypeLoaderTestCaseBase.php @@ -80,6 +80,56 @@ public function testIgnoresNonExistentType(): void self::assertNull($schema->getType('NonExistingType')); } + public function testThrowingTypeLoaderForBuiltInScalarTriggersDeprecation(): void + { + $schema = new Schema([ + 'query' => $this->query, + 'typeLoader' => function (string $name): ?Type { + if (isset(Type::builtInScalars()[$name])) { + throw new \Exception("Type \"{$name}\" not found"); + } + + return ($this->typeLoader)($name); + }, + ]); + + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + if ($errno === \E_USER_DEPRECATED) { + $deprecations[] = $errstr; + + return true; + } + + return false; + }); + + try { + self::assertSame(Type::string(), $schema->getType('String')); + self::assertSame(Type::string(), $schema->getTypeMap()['String']); + + self::assertNotEmpty($deprecations); + self::assertStringContainsString('String', $deprecations[0]); + } finally { + restore_error_handler(); + } + } + + public function testThrowingTypeLoaderForNonBuiltInTypeIsNotCaught(): void + { + $schema = new Schema([ + 'query' => $this->query, + 'typeLoader' => static function (string $name): ?Type { + throw new \Exception("Type \"{$name}\" not found"); + }, + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Type "Node" not found'); + + $schema->getType('Node'); + } + public function testFailsOnNonType(): void { $notType = new \stdClass(); From 21493cf2ca4e7d313b34cb3b61e18c60d33565de Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 17 Mar 2026 10:16:59 +0100 Subject: [PATCH 2/6] Update docs to recommend returning null from typeLoader for unknown types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous example threw an exception for unknown types, which breaks when the library probes the typeLoader for built-in scalar overrides. 🤖 Generated with Claude Code --- docs/schema-definition.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/schema-definition.md b/docs/schema-definition.md index 35b7c2b70..9aac264fb 100644 --- a/docs/schema-definition.md +++ b/docs/schema-definition.md @@ -85,7 +85,7 @@ or an array with the following options: | subscription | `ObjectType` or `callable(): ?ObjectType` or `null` | Reserved for future subscriptions implementation. Currently presented for compatibility with introspection query of **graphql-js**, used by various clients (like Relay or GraphiQL) | | directives | `array` | A full list of [directives](type-definitions/directives.md) supported by your schema. By default, contains built-in **@skip** and **@include** directives.

If you pass your own directives and still want to use built-in directives - add them explicitly. For example:

_array_merge(GraphQL::getStandardDirectives(), [$myCustomDirective]);_ | | types | `array` | List of object types which cannot be detected by **graphql-php** during static schema analysis.

Most often this happens when the object type is never referenced in fields directly but is still a part of a schema because it implements an interface which resolves to this object type in its **resolveType** callable.

Note that you are not required to pass all of your types here - it is simply a workaround for a concrete use-case. | -| typeLoader | `callable(string $name): Type` | Expected to return a type instance given the name. Must always return the same instance if called multiple times, see [lazy loading](#lazy-loading-of-types). See section below on lazy type loading. | +| typeLoader | `callable(string $name): ?Type` | Expected to return a type instance given the name, or `null` for unknown types. Must always return the same instance if called multiple times, see [lazy loading](#lazy-loading-of-types). See section below on lazy type loading. | ### Using config class @@ -164,8 +164,8 @@ final class Types /** @var array */ private static array $types = []; - /** @return Type&NamedType */ - public static function load(string $typeName): Type + /** @return (Type&NamedType)|null */ + public static function load(string $typeName): ?Type { if (isset(self::$types[$typeName])) { return self::$types[$typeName]; @@ -178,7 +178,7 @@ final class Types default => lcfirst($typeName), }; if (! method_exists(self::class, $methodName)) { - throw new \Exception("Unknown GraphQL type: {$typeName}."); + return null; } $type = self::{$methodName}(); // @phpstan-ignore-line variable static method call From 5edd74f9d1776473b99d9fc576983974f26e353b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:18:38 +0000 Subject: [PATCH 3/6] Autofix --- docs/schema-definition.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/schema-definition.md b/docs/schema-definition.md index 9aac264fb..1d7c28f34 100644 --- a/docs/schema-definition.md +++ b/docs/schema-definition.md @@ -85,7 +85,7 @@ or an array with the following options: | subscription | `ObjectType` or `callable(): ?ObjectType` or `null` | Reserved for future subscriptions implementation. Currently presented for compatibility with introspection query of **graphql-js**, used by various clients (like Relay or GraphiQL) | | directives | `array` | A full list of [directives](type-definitions/directives.md) supported by your schema. By default, contains built-in **@skip** and **@include** directives.

If you pass your own directives and still want to use built-in directives - add them explicitly. For example:

_array_merge(GraphQL::getStandardDirectives(), [$myCustomDirective]);_ | | types | `array` | List of object types which cannot be detected by **graphql-php** during static schema analysis.

Most often this happens when the object type is never referenced in fields directly but is still a part of a schema because it implements an interface which resolves to this object type in its **resolveType** callable.

Note that you are not required to pass all of your types here - it is simply a workaround for a concrete use-case. | -| typeLoader | `callable(string $name): ?Type` | Expected to return a type instance given the name, or `null` for unknown types. Must always return the same instance if called multiple times, see [lazy loading](#lazy-loading-of-types). See section below on lazy type loading. | +| typeLoader | `callable(string $name): ?Type` | Expected to return a type instance given the name, or `null` for unknown types. Must always return the same instance if called multiple times, see [lazy loading](#lazy-loading-of-types). See section below on lazy type loading. | ### Using config class From d86966c6f4c6bcf717d4e7bec01f64f1b9183151 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 17 Mar 2026 10:25:43 +0100 Subject: [PATCH 4/6] Add Type::isBuiltInScalarName() for efficient name checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoids allocating the full builtInScalars() array just to check membership. Replaces inline in_array() calls in Type and the isset(builtInScalars()[$name]) pattern in Schema. 🤖 Generated with Claude Code --- src/Type/Definition/Type.php | 9 +++++++-- src/Type/Schema.php | 2 +- tests/Type/TypeLoaderTestCaseBase.php | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Type/Definition/Type.php b/src/Type/Definition/Type.php index 8a9b05647..6eb7208db 100644 --- a/src/Type/Definition/Type.php +++ b/src/Type/Definition/Type.php @@ -203,7 +203,7 @@ public static function overrideStandardTypes(array $types): void throw new InvariantViolation("Expecting instance of {$typeClass}, got {$notType}"); } - if (! in_array($type->name, self::BUILT_IN_SCALAR_NAMES, true)) { + if (! self::isBuiltInScalarName($type->name)) { $standardTypeNames = implode(', ', self::BUILT_IN_SCALAR_NAMES); $notStandardTypeName = Utils::printSafe($type->name); throw new InvariantViolation("Expecting one of the following names for a standard type: {$standardTypeNames}; got {$notStandardTypeName}"); @@ -228,7 +228,12 @@ public static function overrideStandardTypes(array $types): void public static function isBuiltInScalar($type): bool { return $type instanceof ScalarType - && in_array($type->name, self::BUILT_IN_SCALAR_NAMES, true); + && self::isBuiltInScalarName($type->name); + } + + public static function isBuiltInScalarName(string $name): bool + { + return in_array($name, self::BUILT_IN_SCALAR_NAMES, true); } /** diff --git a/src/Type/Schema.php b/src/Type/Schema.php index 83137fd81..173046aec 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -378,7 +378,7 @@ private function loadType(string $typeName): ?Type try { $type = ($this->config->typeLoader)($typeName); } catch (\Throwable $e) { - if (isset(Type::builtInScalars()[$typeName])) { + if (Type::isBuiltInScalarName($typeName)) { @trigger_error( "Type loader should return null for unknown types, but threw for built-in scalar \"{$typeName}\": {$e->getMessage()}. In a future major version, this exception will not be caught.", \E_USER_DEPRECATED, diff --git a/tests/Type/TypeLoaderTestCaseBase.php b/tests/Type/TypeLoaderTestCaseBase.php index da96dbc54..06128813b 100644 --- a/tests/Type/TypeLoaderTestCaseBase.php +++ b/tests/Type/TypeLoaderTestCaseBase.php @@ -85,7 +85,7 @@ public function testThrowingTypeLoaderForBuiltInScalarTriggersDeprecation(): voi $schema = new Schema([ 'query' => $this->query, 'typeLoader' => function (string $name): ?Type { - if (isset(Type::builtInScalars()[$name])) { + if (Type::isBuiltInScalarName($name)) { throw new \Exception("Type \"{$name}\" not found"); } From 17288e56121fe4ef464e04bc33cc695ced74d9b5 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 17 Mar 2026 11:08:17 +0100 Subject: [PATCH 5/6] Use Warning infrastructure for throwing type loader deprecation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses https://github.com/webonyx/graphql-php/pull/1879#pullrequestreview-3959296870 Consumers can now suppress via Warning::suppress(Warning::WARNING_CONFIG_DEPRECATION). 🤖 Generated with Claude Code --- src/Type/Schema.php | 20 ++++++++++++-------- tests/Type/TypeLoaderTestCaseBase.php | 20 ++++++++------------ 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Type/Schema.php b/src/Type/Schema.php index 173046aec..9a231a4ec 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -4,6 +4,7 @@ use GraphQL\Error\Error; use GraphQL\Error\InvariantViolation; +use GraphQL\Error\Warning; use GraphQL\GraphQL; use GraphQL\Language\AST\OperationDefinitionNode; use GraphQL\Language\AST\SchemaDefinitionNode; @@ -197,10 +198,7 @@ public function getTypeMap(): array try { $type = ($this->config->typeLoader)($scalarName); } catch (\Throwable $e) { - @trigger_error( - "Type loader should return null for unknown types, but threw for built-in scalar \"{$scalarName}\": {$e->getMessage()}. In a future major version, this exception will not be caught.", - \E_USER_DEPRECATED, - ); + self::deprecateThrowingTypeLoaderForBuiltInScalar($scalarName, $e); continue; } @@ -234,6 +232,15 @@ public function getDirectives(): array return $this->config->directives ?? GraphQL::getStandardDirectives(); } + private static function deprecateThrowingTypeLoaderForBuiltInScalar(string $scalarName, \Throwable $e): void + { + Warning::warn( + "Type loader should return null for unknown types, but threw for built-in scalar \"{$scalarName}\": {$e->getMessage()}. In a future major version, this exception will not be caught.", + Warning::WARNING_CONFIG_DEPRECATION, + \E_USER_DEPRECATED, + ); + } + /** @param mixed $typeLoaderReturn could be anything */ public static function typeLoaderNotType($typeLoaderReturn): string { @@ -379,10 +386,7 @@ private function loadType(string $typeName): ?Type $type = ($this->config->typeLoader)($typeName); } catch (\Throwable $e) { if (Type::isBuiltInScalarName($typeName)) { - @trigger_error( - "Type loader should return null for unknown types, but threw for built-in scalar \"{$typeName}\": {$e->getMessage()}. In a future major version, this exception will not be caught.", - \E_USER_DEPRECATED, - ); + self::deprecateThrowingTypeLoaderForBuiltInScalar($typeName, $e); return null; } diff --git a/tests/Type/TypeLoaderTestCaseBase.php b/tests/Type/TypeLoaderTestCaseBase.php index 06128813b..79ee66c51 100644 --- a/tests/Type/TypeLoaderTestCaseBase.php +++ b/tests/Type/TypeLoaderTestCaseBase.php @@ -4,6 +4,7 @@ use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; use GraphQL\Error\InvariantViolation; +use GraphQL\Error\Warning; use GraphQL\Tests\TestCaseBase; use GraphQL\Type\Definition\NamedType; use GraphQL\Type\Definition\ObjectType; @@ -93,25 +94,20 @@ public function testThrowingTypeLoaderForBuiltInScalarTriggersDeprecation(): voi }, ]); - $deprecations = []; - set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { - if ($errno === \E_USER_DEPRECATED) { - $deprecations[] = $errstr; - - return true; - } - - return false; + $warnings = []; + Warning::setWarningHandler(static function (string $message, int $warningId) use (&$warnings): void { + $warnings[] = ['message' => $message, 'id' => $warningId]; }); try { self::assertSame(Type::string(), $schema->getType('String')); self::assertSame(Type::string(), $schema->getTypeMap()['String']); - self::assertNotEmpty($deprecations); - self::assertStringContainsString('String', $deprecations[0]); + self::assertNotEmpty($warnings); + self::assertSame(Warning::WARNING_CONFIG_DEPRECATION, $warnings[0]['id']); + self::assertStringContainsString('String', $warnings[0]['message']); } finally { - restore_error_handler(); + Warning::setWarningHandler(null); } } From 7d5bc8f7b63d948428782ab04360cd9d0b8e63e6 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 17 Mar 2026 11:47:39 +0100 Subject: [PATCH 6/6] Fix PHPStan callable narrowing inside try/catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assign typeLoader to local variable so PHPStan retains the isset() type narrowing through the try block. 🤖 Generated with Claude Code --- src/Type/Schema.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Type/Schema.php b/src/Type/Schema.php index 9a231a4ec..bb326300c 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -190,13 +190,14 @@ public function getTypeMap(): array } if (isset($this->config->typeLoader)) { + $typeLoader = $this->config->typeLoader; foreach (Type::BUILT_IN_SCALAR_NAMES as $scalarName) { if (isset($scalarOverrides[$scalarName])) { continue; } try { - $type = ($this->config->typeLoader)($scalarName); + $type = $typeLoader($scalarName); } catch (\Throwable $e) { self::deprecateThrowingTypeLoaderForBuiltInScalar($scalarName, $e); continue;