From 86b883808fa08ba9f7d3344ad8ee60886062fa0c Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 29 Jul 2026 10:37:14 +0200 Subject: [PATCH 1/3] Reapply reading properties of objects that implement \ArrayAccess Restores https://github.com/webonyx/graphql-php/pull/1531, reverted in v15.37.1 because it exposed internal object state as field values. Also removes the regression test added by the revert, which pinned the behavior this change undoes. --- src/Utils/Utils.php | 8 +++- tests/Executor/ExecutorTest.php | 76 +++------------------------------ 2 files changed, 12 insertions(+), 72 deletions(-) diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php index 73b7a9f04..cbffe2588 100644 --- a/src/Utils/Utils.php +++ b/src/Utils/Utils.php @@ -268,10 +268,16 @@ public static function suggestionList(string $input, array $options): array */ public static function extractKey($objectLikeValue, string $key) { - if (is_array($objectLikeValue) || $objectLikeValue instanceof \ArrayAccess) { + if (is_array($objectLikeValue)) { return $objectLikeValue[$key] ?? null; } + if ($objectLikeValue instanceof \ArrayAccess) { + return $objectLikeValue[$key] + ?? $objectLikeValue->{$key} // @phpstan-ignore-line Variable property access on ArrayAccess is fine here, we do the same for arbitrary objects + ?? null; + } + if (is_object($objectLikeValue)) { return $objectLikeValue->{$key} ?? null; } diff --git a/tests/Executor/ExecutorTest.php b/tests/Executor/ExecutorTest.php index f59eb7cb5..065356e96 100644 --- a/tests/Executor/ExecutorTest.php +++ b/tests/Executor/ExecutorTest.php @@ -1190,6 +1190,7 @@ public function testDefaultResolverGrabsValuesOffOfCommonPhpDataStructures(): vo 'name' => 'ArrayAccess', 'fields' => [ 'set' => Type::int(), + 'setProperty' => Type::int(), 'unsetNull' => Type::int(), 'unsetThrow' => Type::int(), ], @@ -1224,6 +1225,8 @@ public function testDefaultResolverGrabsValuesOffOfCommonPhpDataStructures(): vo 'arrayAccess' => [ 'type' => $ArrayAccess, 'resolve' => static fn (): \ArrayAccess => new class implements \ArrayAccess { + public ?int $setProperty = 1; + /** @param mixed $offset */ #[\ReturnTypeWillChange] public function offsetExists($offset): bool @@ -1317,6 +1320,7 @@ public function __get(string $name): ?int } arrayAccess { set + setProperty unsetNull unsetThrow } @@ -1344,6 +1348,7 @@ public function __get(string $name): ?int ], 'arrayAccess' => [ 'set' => 1, + 'setProperty' => 1, 'unsetNull' => null, 'unsetThrow' => null, ], @@ -1362,75 +1367,4 @@ public function __get(string $name): ?int $result->toArray() ); } - - public function testDefaultResolverDoesNotAccessPropertiesOfArrayAccess(): void - { - $schema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'arrayAccess' => [ - 'type' => new ObjectType([ - 'name' => 'ArrayAccess', - 'fields' => [ - 'property' => Type::int(), - ], - ]), - // Eloquent models implement \ArrayAccess to expose their attributes. - // Their properties hold internal state that must stay hidden. - // https://github.com/webonyx/graphql-php/pull/1531 - 'resolve' => static fn (): \ArrayAccess => new class implements \ArrayAccess { - public ?int $property = 1; - - /** @param mixed $offset */ - #[\ReturnTypeWillChange] - public function offsetExists($offset): bool - { - return false; - } - - /** @param mixed $offset */ - #[\ReturnTypeWillChange] - public function offsetGet($offset): ?int - { - return null; - } - - /** - * @param mixed $offset - * @param mixed $value - */ - #[\ReturnTypeWillChange] - public function offsetSet($offset, $value): void {} - - /** @param mixed $offset */ - #[\ReturnTypeWillChange] - public function offsetUnset($offset): void {} - }, - ], - ], - ]), - ]); - - $query = Parser::parse(' - { - arrayAccess { - property - } - } - '); - - $result = Executor::execute($schema, $query); - - self::assertSame( - [ - 'data' => [ - 'arrayAccess' => [ - 'property' => null, - ], - ], - ], - $result->toArray() - ); - } } From 8674466520b1cfcc3d4a95e7e44aab4f080aebb1 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 29 Jul 2026 10:38:28 +0200 Subject: [PATCH 2/3] Add changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 940edd760..ea6ee0f4a 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 +### Added + +- If an object implements `\ArrayAccess`, check both array value and property https://github.com/webonyx/graphql-php/pull/1960 + ## v15.37.1 ### Fixed From 7651c641c0fcb7b1fc1a113a874c016372c29452 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 28 Sep 2026 11:46:53 +0200 Subject: [PATCH 3/3] Gate ArrayAccess property fallback behind a marker interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only values implementing ArrayAccessPropertyFallback fall back to properties. Plain \ArrayAccess values such as Laravel collections, Eloquent models and Drupal field lists keep reading offsets only. Restores the regression test from #1958. 🤖 Generated with Claude Code --- CHANGELOG.md | 2 +- docs/class-reference.md | 11 + docs/data-fetching.md | 3 + generate-class-reference.php | 1 + src/Executor/ArrayAccessPropertyFallback.php | 15 ++ src/Utils/Utils.php | 13 +- .../ArrayAccessPropertyFallbackTest.php | 195 ++++++++++++++++++ tests/Executor/ExecutorTest.php | 76 ++++++- .../TestClasses/ArrayAccessAttributes.php | 55 +++++ 9 files changed, 359 insertions(+), 12 deletions(-) create mode 100644 src/Executor/ArrayAccessPropertyFallback.php create mode 100644 tests/Executor/ArrayAccessPropertyFallbackTest.php create mode 100644 tests/Executor/TestClasses/ArrayAccessAttributes.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f7de60d32..1860104d8 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 ### Added -- If an object implements `\ArrayAccess`, check both array value and property https://github.com/webonyx/graphql-php/pull/1960 +- Add interface `ArrayAccessPropertyFallback` to let the default field resolver read properties of `\ArrayAccess` objects https://github.com/webonyx/graphql-php/pull/1960 ## v15.37.2 diff --git a/docs/class-reference.md b/docs/class-reference.md index 81cd48e81..92b818564 100644 --- a/docs/class-reference.md +++ b/docs/class-reference.md @@ -1704,6 +1704,17 @@ When the object passed as `$contextValue` to GraphQL execution implements this, its `clone()` method will be called before passing the context down to a field. This allows passing information to child fields in the query tree without affecting sibling or parent fields. +## GraphQL\Executor\ArrayAccessPropertyFallback + +When a value implementing this is resolved by the default field resolver, +a key that is not set by array access is read from the property of the same name. +Plain `\ArrayAccess` values only use array access, so their properties stay hidden. + +@template TKey +@template TValue + +@extends \ArrayAccess + ## GraphQL\Executor\ExecutionResult Returned after [query execution](executing-queries.md). diff --git a/docs/data-fetching.md b/docs/data-fetching.md index 13fad0136..04293dcf5 100644 --- a/docs/data-fetching.md +++ b/docs/data-fetching.md @@ -119,6 +119,9 @@ function defaultFieldResolver($objectValue, array $args, $context, ResolveInfo $ It returns value by key (for arrays) or property (for objects). If the value is not set, it returns **null**. +Objects that implement `ArrayAccess` are read by key only, so their properties stay hidden. +Implement `GraphQL\Executor\ArrayAccessPropertyFallback` instead of `ArrayAccess` to fall back to the property when the key is not set. + To override the default resolver, pass it as an argument to [executeQuery](executing-queries.md). ## Default Field Resolver per Type diff --git a/generate-class-reference.php b/generate-class-reference.php index 841ee88b4..9f5b1bd3c 100644 --- a/generate-class-reference.php +++ b/generate-class-reference.php @@ -21,6 +21,7 @@ GraphQL\Language\AST\NodeKind::class => ['constants' => true], GraphQL\Executor\Executor::class => [], GraphQL\Executor\ScopedContext::class => [], + GraphQL\Executor\ArrayAccessPropertyFallback::class => [], GraphQL\Executor\ExecutionResult::class => [], GraphQL\Executor\Promise\PromiseAdapter::class => [], GraphQL\Deferred::class => [], diff --git a/src/Executor/ArrayAccessPropertyFallback.php b/src/Executor/ArrayAccessPropertyFallback.php new file mode 100644 index 000000000..38ad7bc2c --- /dev/null +++ b/src/Executor/ArrayAccessPropertyFallback.php @@ -0,0 +1,15 @@ + + */ +interface ArrayAccessPropertyFallback extends \ArrayAccess {} diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php index cbffe2588..b3253906d 100644 --- a/src/Utils/Utils.php +++ b/src/Utils/Utils.php @@ -4,6 +4,7 @@ use GraphQL\Error\Error; use GraphQL\Error\Warning; +use GraphQL\Executor\ArrayAccessPropertyFallback; use GraphQL\Language\AST\Node; class Utils @@ -268,16 +269,16 @@ public static function suggestionList(string $input, array $options): array */ public static function extractKey($objectLikeValue, string $key) { - if (is_array($objectLikeValue)) { - return $objectLikeValue[$key] ?? null; - } - - if ($objectLikeValue instanceof \ArrayAccess) { + if ($objectLikeValue instanceof ArrayAccessPropertyFallback) { return $objectLikeValue[$key] - ?? $objectLikeValue->{$key} // @phpstan-ignore-line Variable property access on ArrayAccess is fine here, we do the same for arbitrary objects + ?? $objectLikeValue->{$key} // @phpstan-ignore-line Variable property access is what the implementor opted into ?? null; } + if (is_array($objectLikeValue) || $objectLikeValue instanceof \ArrayAccess) { + return $objectLikeValue[$key] ?? null; + } + if (is_object($objectLikeValue)) { return $objectLikeValue->{$key} ?? null; } diff --git a/tests/Executor/ArrayAccessPropertyFallbackTest.php b/tests/Executor/ArrayAccessPropertyFallbackTest.php new file mode 100644 index 000000000..6408b52fb --- /dev/null +++ b/tests/Executor/ArrayAccessPropertyFallbackTest.php @@ -0,0 +1,195 @@ + 'attribute', 'shadowed' => 'attribute', 'nullAttribute' => null]) extends ArrayAccessAttributes implements ArrayAccessPropertyFallback { + public string $shadowed = 'property'; + + public string $property = 'property'; + + public string $nullAttribute = 'property'; + }; + + self::assertSame( + [ + 'data' => [ + 'attribute' => 'attribute', + 'shadowed' => 'attribute', + 'property' => 'property', + 'nullAttribute' => 'property', + 'missing' => null, + ], + ], + self::executeFields(['attribute', 'shadowed', 'property', 'nullAttribute', 'missing'], $rootValue) + ); + } + + public function testHidesPropertiesOfEloquentLikeModel(): void + { + $rootValue = new class(['name' => null]) extends ArrayAccessAttributes { + public bool $exists = true; + + public bool $wasRecentlyCreated = false; + + public string $name = 'property'; + }; + + self::assertSame( + [ + 'data' => [ + 'exists' => null, + 'wasRecentlyCreated' => null, + 'name' => null, + ], + ], + self::executeFields(['exists', 'wasRecentlyCreated', 'name'], $rootValue) + ); + } + + public function testDoesNotCallMagicGetOfCollectionLikeValue(): void + { + $rootValue = new class(['present' => 1]) extends ArrayAccessAttributes { + /** + * @throws \Exception + * + * @return never + */ + public function __get(string $key) + { + throw new \Exception("Property [{$key}] does not exist on this collection instance."); + } + }; + + self::assertSame( + [ + 'data' => [ + 'present' => '1', + 'missing' => null, + ], + ], + self::executeFields(['present', 'missing'], $rootValue) + ); + } + + public function testDoesNotForwardToFirstItemOfFieldItemListLikeValue(): void + { + $rootValue = new class([]) extends ArrayAccessAttributes { + public function __isset(string $name): bool + { + return true; + } + + public function __get(string $name): string + { + return 'referenced entity'; + } + }; + + self::assertSame( + ['data' => ['entity' => null]], + self::executeFields(['entity'], $rootValue) + ); + } + + public function testIgnoresTypenamePropertyOfArrayAccess(): void + { + $pet = new class([]) extends ArrayAccessAttributes { + public string $__typename = 'Cat'; + }; + + self::assertSame( + ['data' => ['pet' => ['__typename' => 'Dog']]], + self::executePet($pet) + ); + } + + public function testReadsTypenamePropertyOfOptedInArrayAccess(): void + { + $pet = new class([]) extends ArrayAccessAttributes implements ArrayAccessPropertyFallback { + public string $__typename = 'Cat'; + }; + + self::assertSame( + ['data' => ['pet' => ['__typename' => 'Cat']]], + self::executePet($pet) + ); + } + + /** + * @param list $fieldNames + * @param \ArrayAccess $rootValue + * + * @throws \Exception + * @throws InvariantViolation + * + * @return array + */ + private static function executeFields(array $fieldNames, \ArrayAccess $rootValue): array + { + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => array_fill_keys($fieldNames, Type::string()), + ]), + ]); + + $query = '{ ' . implode(' ', $fieldNames) . ' }'; + + return GraphQL::executeQuery($schema, $query, $rootValue)->toArray(); + } + + /** + * @param \ArrayAccess $pet + * + * @throws \Exception + * @throws InvariantViolation + * + * @return array + */ + private static function executePet(\ArrayAccess $pet): array + { + $dog = new ObjectType([ + 'name' => 'Dog', + 'fields' => ['name' => Type::string()], + 'isTypeOf' => static fn (): bool => true, + ]); + $cat = new ObjectType([ + 'name' => 'Cat', + 'fields' => ['name' => Type::string()], + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'pet' => [ + 'type' => new UnionType([ + 'name' => 'Pet', + 'types' => [$dog, $cat], + ]), + 'resolve' => static fn (): \ArrayAccess => $pet, + ], + ], + ]), + ]); + + return GraphQL::executeQuery($schema, '{ pet { __typename } }')->toArray(); + } +} diff --git a/tests/Executor/ExecutorTest.php b/tests/Executor/ExecutorTest.php index 065356e96..f59eb7cb5 100644 --- a/tests/Executor/ExecutorTest.php +++ b/tests/Executor/ExecutorTest.php @@ -1190,7 +1190,6 @@ public function testDefaultResolverGrabsValuesOffOfCommonPhpDataStructures(): vo 'name' => 'ArrayAccess', 'fields' => [ 'set' => Type::int(), - 'setProperty' => Type::int(), 'unsetNull' => Type::int(), 'unsetThrow' => Type::int(), ], @@ -1225,8 +1224,6 @@ public function testDefaultResolverGrabsValuesOffOfCommonPhpDataStructures(): vo 'arrayAccess' => [ 'type' => $ArrayAccess, 'resolve' => static fn (): \ArrayAccess => new class implements \ArrayAccess { - public ?int $setProperty = 1; - /** @param mixed $offset */ #[\ReturnTypeWillChange] public function offsetExists($offset): bool @@ -1320,7 +1317,6 @@ public function __get(string $name): ?int } arrayAccess { set - setProperty unsetNull unsetThrow } @@ -1348,7 +1344,6 @@ public function __get(string $name): ?int ], 'arrayAccess' => [ 'set' => 1, - 'setProperty' => 1, 'unsetNull' => null, 'unsetThrow' => null, ], @@ -1367,4 +1362,75 @@ public function __get(string $name): ?int $result->toArray() ); } + + public function testDefaultResolverDoesNotAccessPropertiesOfArrayAccess(): void + { + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'arrayAccess' => [ + 'type' => new ObjectType([ + 'name' => 'ArrayAccess', + 'fields' => [ + 'property' => Type::int(), + ], + ]), + // Eloquent models implement \ArrayAccess to expose their attributes. + // Their properties hold internal state that must stay hidden. + // https://github.com/webonyx/graphql-php/pull/1531 + 'resolve' => static fn (): \ArrayAccess => new class implements \ArrayAccess { + public ?int $property = 1; + + /** @param mixed $offset */ + #[\ReturnTypeWillChange] + public function offsetExists($offset): bool + { + return false; + } + + /** @param mixed $offset */ + #[\ReturnTypeWillChange] + public function offsetGet($offset): ?int + { + return null; + } + + /** + * @param mixed $offset + * @param mixed $value + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value): void {} + + /** @param mixed $offset */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset): void {} + }, + ], + ], + ]), + ]); + + $query = Parser::parse(' + { + arrayAccess { + property + } + } + '); + + $result = Executor::execute($schema, $query); + + self::assertSame( + [ + 'data' => [ + 'arrayAccess' => [ + 'property' => null, + ], + ], + ], + $result->toArray() + ); + } } diff --git a/tests/Executor/TestClasses/ArrayAccessAttributes.php b/tests/Executor/TestClasses/ArrayAccessAttributes.php new file mode 100644 index 000000000..ec2653508 --- /dev/null +++ b/tests/Executor/TestClasses/ArrayAccessAttributes.php @@ -0,0 +1,55 @@ + + */ +class ArrayAccessAttributes implements \ArrayAccess +{ + /** @var array */ + private array $attributes; + + /** @param array $attributes */ + public function __construct(array $attributes) + { + $this->attributes = $attributes; + } + + /** @param mixed $offset */ + #[\ReturnTypeWillChange] + public function offsetExists($offset): bool + { + return isset($this->attributes[$offset]); + } + + /** + * @param mixed $offset + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->attributes[$offset] ?? null; + } + + /** + * @param mixed $offset + * @param mixed $value + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value): void + { + $this->attributes[$offset] = $value; + } + + /** @param mixed $offset */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset): void + { + unset($this->attributes[$offset]); + } +}