Skip to content

Cache result of validation #1730

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 41 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
35ce576
cache result of validation
shmax Jul 11, 2025
03db33d
remove ttl
shmax Jul 11, 2025
c3a7eb4
stanning
shmax Jul 11, 2025
a2c38f4
stanning
shmax Jul 11, 2025
6782bad
downgrade to satisfy 7.4
shmax Jul 11, 2025
6317931
use custom interface
shmax Jul 12, 2025
73c9774
files
shmax Jul 12, 2025
6d7ace0
cleanup
shmax Jul 12, 2025
708c85b
cleanup
shmax Jul 12, 2025
f35cb3c
remove trailing comma
shmax Jul 12, 2025
d71e123
fix tests
shmax Jul 12, 2025
cf20ef3
formatting
shmax Jul 14, 2025
60bffe6
move to file
shmax Jul 14, 2025
40c000b
use shorthand, formatting
shmax Jul 14, 2025
7d515fd
assert both results
shmax Jul 14, 2025
c2a82a4
ignore specific error
shmax Jul 14, 2025
51d1087
documentation
shmax Jul 15, 2025
a745345
Autofix
autofix-ci[bot] Jul 15, 2025
1277bb3
use serialize
shmax Jul 16, 2025
59a0cd7
simplify conditional
spawnia Jul 17, 2025
6679cb8
unify formatting
spawnia Jul 17, 2025
8d21f38
Merge branch 'master' into validation-cache
spawnia Jul 17, 2025
de245ff
Polish tests and docs
spawnia Jul 17, 2025
d166e41
Clean up types
spawnia Jul 17, 2025
4b3aafe
include rules in the fun
shmax Jul 18, 2025
9683c02
Autofix
autofix-ci[bot] Jul 18, 2025
02013c3
update comments
shmax Jul 18, 2025
1e55f48
Merge branch 'validation-cache' of github.com:shmax/graphql-php into …
shmax Jul 18, 2025
127b68f
remove comment
shmax Jul 18, 2025
b4865e9
add comments about keys
shmax Jul 23, 2025
4ec12df
pass rules to markValidated
shmax Jul 23, 2025
707f257
add docs
shmax Jul 23, 2025
4c3d419
formatting
shmax Jul 23, 2025
b5fdd3e
remove redundant
shmax Jul 23, 2025
5d9e714
Autofix
autofix-ci[bot] Jul 23, 2025
b8c62dc
Improve docs
spawnia Jul 23, 2025
7a2b3dc
Autofix
autofix-ci[bot] Jul 23, 2025
e8fedcc
make it more clear that samples are using hand-rolled classes
shmax Jul 23, 2025
c91420c
Merge branch 'validation-cache' of github.com:shmax/graphql-php into …
shmax Jul 23, 2025
22e15cc
remove see reference
shmax Jul 23, 2025
cbebfce
Autofix
autofix-ci[bot] Jul 23, 2025
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
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@
"phpstan/phpstan-strict-rules": "2.0.4",
"phpunit/phpunit": "^9.5 || ^10.5.21 || ^11",
"psr/http-message": "^1 || ^2",
"psr/simple-cache": "^1.0",
"react/http": "^1.6",
"react/promise": "^2.0 || ^3.0",
"rector/rector": "^2.0",
"symfony/cache": "^5.4",
"symfony/polyfill-php81": "^1.23",
"symfony/var-exporter": "^5 || ^6 || ^7",
"thecodingmachine/safe": "^1.3 || ^2 || ^3"
Expand Down
9 changes: 6 additions & 3 deletions docs/class-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ static function executeQuery(
?array $variableValues = null,
?string $operationName = null,
?callable $fieldResolver = null,
?array $validationRules = null
?array $validationRules = null,
?GraphQL\Validator\ValidationCache $cache = null
): GraphQL\Executor\ExecutionResult
```

Expand Down Expand Up @@ -98,7 +99,8 @@ static function promiseToExecute(
?array $variableValues = null,
?string $operationName = null,
?callable $fieldResolver = null,
?array $validationRules = null
?array $validationRules = null,
?GraphQL\Validator\ValidationCache $cache = null
): GraphQL\Executor\Promise\Promise
```

Expand Down Expand Up @@ -1811,7 +1813,8 @@ static function validate(
GraphQL\Type\Schema $schema,
GraphQL\Language\AST\DocumentNode $ast,
?array $rules = null,
?GraphQL\Utils\TypeInfo $typeInfo = null
?GraphQL\Utils\TypeInfo $typeInfo = null,
?GraphQL\Validator\ValidationCache $cache = null
): array
```

Expand Down
98 changes: 98 additions & 0 deletions docs/executing-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,101 @@ $server = new StandardServer([
'validationRules' => $myValidationRules
]);
```

## Validation Caching

Validation is a required step in GraphQL execution, but it can become a performance bottleneck when the same queries are
run repeatedly — especially in production environments where queries are often static or pre-generated (e.g., persisted
queries or queries emitted by client libraries).

To optimize for this, `graphql-php` supports pluggable validation caching. By implementing the `GraphQL\Validator\ValidationCache` interface and passing it to
`GraphQL::executeQuery()`, you can skip validation for queries that are already known to be valid.

```php
use GraphQL\Validator\ValidationCache;
use GraphQL\GraphQL;
use GraphQL\Tests\PsrValidationCacheAdapter;

$validationCache = new PsrValidationCacheAdapter();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean to expose this class to end users? The directory /tests and subsequently the namespace GraphQL\Tests is excluded from the composer package through .gitattributes. My impression was that it serves only as a vehicle for our own unit tests and perhaps as an example implementation that we can point to.

I would probably recommend users to implement the interface concretely, perhaps we can just add something like this?

Suggested change
use GraphQL\GraphQL;
use GraphQL\Tests\PsrValidationCacheAdapter;
$validationCache = new PsrValidationCacheAdapter();
use GraphQL\GraphQL;
final class MyValidationCache implements ValidationCache { ... }
$validationCache = new MyValidationCache();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, yeah, this is just meant to be a sample. I'll touch that up.


$result = GraphQL::executeQuery(
$schema,
$queryString,
$rootValue,
$context,
$variableValues,
$operationName,
$fieldResolver,
$validationRules,
$validationCache
);
```

### Key Generation Tips

You are responsible for generating your own cache keys in a way that uniquely identifies the schema, the query, and
(optionally) any custom validation rules. Here are some tips:

- Hash your schema once at build time and store the result in an environment variable or constant.
- Avoid using serialize() on schema objects — closures and internal references may cause errors.
- If using custom validation rules, be sure to account for them in your key (e.g., by serializing or listing their class names).
- Consider including the graphql-php version number to account for internal rule changes across versions.

### Sample Implementation

```php
use GraphQL\Validator\ValidationCache;
use GraphQL\Language\AST\DocumentNode;
use GraphQL\Type\Schema;
use GraphQL\Utils\SchemaPrinter;
use Psr\SimpleCache\CacheInterface;
use Composer\InstalledVersions;

/**
* Reference implementation of ValidationCache using PSR-16 cache.
*
* @see GraphQl\Tests\PsrValidationCacheAdapter
*/
class PsrValidationCacheAdapter implements ValidationCache
{
private CacheInterface $cache;

private int $ttlSeconds;

public function __construct(
CacheInterface $cache,
int $ttlSeconds = 300
) {
$this->cache = $cache;
$this->ttlSeconds = $ttlSeconds;
}

public function isValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): bool
{
$key = $this->buildKey($schema, $ast);
return $this->cache->has($key);
}

public function markValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): void
{
$key = $this->buildKey($schema, $ast);
$this->cache->set($key, true, $this->ttlSeconds);
}

private function buildKey(Schema $schema, DocumentNode $ast, ?array $rules = null): string
{
// Use a stable hash for schema. In production, prefer a build-time constant:
// $schemaHash = $_ENV['SCHEMA_VERSION'] ?? 'v1';
$schemaHash = md5(SchemaPrinter::doPrint($schema));

// Serialize AST and rules — both are predictable and safe in this context
$astHash = md5(serialize($ast));
$rulesHash = md5(serialize($rules));

// Include graphql-php version to account for internal changes
$libraryVersion = \Composer\InstalledVersions::getVersion('webonyx/graphql-php') ?: 'unknown';

return "graphql_validation_{$libraryVersion}_{$schemaHash}_{$astHash}_{$rulesHash}";
}
}
```
12 changes: 8 additions & 4 deletions src/GraphQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use GraphQL\Validator\DocumentValidator;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\Rules\ValidationRule;
use GraphQL\Validator\ValidationCache;

/**
* This is the primary facade for fulfilling GraphQL operations.
Expand Down Expand Up @@ -90,7 +91,8 @@ public static function executeQuery(
?array $variableValues = null,
?string $operationName = null,
?callable $fieldResolver = null,
?array $validationRules = null
?array $validationRules = null,
?ValidationCache $cache = null
): ExecutionResult {
$promiseAdapter = new SyncPromiseAdapter();

Expand All @@ -103,7 +105,8 @@ public static function executeQuery(
$variableValues,
$operationName,
$fieldResolver,
$validationRules
$validationRules,
$cache
);

return $promiseAdapter->wait($promise);
Expand Down Expand Up @@ -132,7 +135,8 @@ public static function promiseToExecute(
?array $variableValues = null,
?string $operationName = null,
?callable $fieldResolver = null,
?array $validationRules = null
?array $validationRules = null,
?ValidationCache $cache = null
): Promise {
try {
$documentNode = $source instanceof DocumentNode
Expand All @@ -152,7 +156,7 @@ public static function promiseToExecute(
}
}

$validationErrors = DocumentValidator::validate($schema, $documentNode, $validationRules);
$validationErrors = DocumentValidator::validate($schema, $documentNode, $validationRules, null, $cache);

if ($validationErrors !== []) {
return $promiseAdapter->createFulfilled(
Expand Down
4 changes: 2 additions & 2 deletions src/Server/ServerConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ public static function create(array $config = []): self
private bool $queryBatching = false;

/**
* @var array<ValidationRule>|callable|null
* @var array|callable|null
*
* @phpstan-var ValidationRulesOption
*/
Expand Down Expand Up @@ -315,7 +315,7 @@ public function getPromiseAdapter(): ?PromiseAdapter
}

/**
* @return array<ValidationRule>|callable|null
* @return array|callable|null
*
* @phpstan-return ValidationRulesOption
*/
Expand Down
24 changes: 17 additions & 7 deletions src/Validator/DocumentValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,20 +99,25 @@ public static function validate(
Schema $schema,
DocumentNode $ast,
?array $rules = null,
?TypeInfo $typeInfo = null
?TypeInfo $typeInfo = null,
?ValidationCache $cache = null
): array {
$rules ??= static::allRules();
if (isset($cache)) {
if ($cache->isValidated($schema, $ast, $rules)) {
return [];
}
}

if ($rules === []) {
$finalRules = $rules ?? static::allRules();
if ($finalRules === []) {
return [];
}

$typeInfo ??= new TypeInfo($schema);

$context = new QueryValidationContext($schema, $ast, $typeInfo);

$visitors = [];
foreach ($rules as $rule) {
foreach ($finalRules as $rule) {
$visitors[] = $rule->getVisitor($context);
}

Expand All @@ -124,7 +129,13 @@ public static function validate(
)
);

return $context->getErrors();
$errors = $context->getErrors();

if (isset($cache) && $errors === []) {
$cache->markValidated($schema, $ast, $rules);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on not overwriting $rules 👍

}

return $errors;
}

/**
Expand Down Expand Up @@ -273,7 +284,6 @@ public static function validateSDL(
?array $rules = null
): array {
$rules ??= self::sdlRules();

if ($rules === []) {
return [];
}
Expand Down
49 changes: 49 additions & 0 deletions src/Validator/ValidationCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php declare(strict_types=1);

namespace GraphQL\Validator;

use GraphQL\Language\AST\DocumentNode;
use GraphQL\Tests\PsrValidationCacheAdapter;
use GraphQL\Type\Schema;
use GraphQL\Validator\Rules\ValidationRule;

/**
* Implement this interface and pass an instance to GraphQL::executeQuery to enable caching of successful query validations.
*
* This can improve performance by skipping validation for known-good combinations of query, schema, and rules.
* You are responsible for defining how cache keys are computed.
*
* Some things to keep in mind when generating keys:
* - PHP's `serialize` method is fast, but can't handle certain structures such as closures.
* - If your `schema` includes closures or is too large or complex to serialize,
* consider using a build-time version number or environment-based fingerprint instead.
* - Keep in mind that there are internal `rules` that are applied in addition to any you pass in,
* and it's possible these may shift or expand as the library evolves, so it might make sense
* to include the library version number in your keys.
*
* @see PsrValidationCacheAdapter for a simple reference implementation.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Due to /tests not being exported, this reference will lead nowhere for users that installed this package.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figure devs will be savvy enough to either clone the repo or look on github, but I can remove it for now if you like.

*/
interface ValidationCache
{
/**
* Determine whether the given schema/AST/rules set has already been successfully validated.
*
* This method should return true if the query has previously passed validation for the provided schema.
* Only successful validations should be considered "cached" — failed validations are not cached.
*
* @param array<ValidationRule>|null $rules
*
* @return bool true if validation for the given schema + AST + rules is already known to be valid; false otherwise
*/
public function isValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): bool;

/**
* @param array<ValidationRule>|null $rules
*
* Mark the given schema/AST/rules set as successfully validated.
*
* This is typically called after a query passes validation.
* You should store enough information to recognize this combination on future requests.
*/
public function markValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): void;
}
28 changes: 28 additions & 0 deletions tests/Executor/TestClasses/SpyValidationCacheAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php declare(strict_types=1);

namespace GraphQL\Tests\Executor\TestClasses;

use GraphQL\Language\AST\DocumentNode;
use GraphQL\Tests\PsrValidationCacheAdapter;
use GraphQL\Type\Schema;

final class SpyValidationCacheAdapter extends PsrValidationCacheAdapter
{
public int $isValidatedCalls = 0;

public int $markValidatedCalls = 0;

public function isValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): bool
{
++$this->isValidatedCalls;

return parent::isValidated($schema, $ast);
}

public function markValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): void
{
++$this->markValidatedCalls;

parent::markValidated($schema, $ast);
}
}
Loading
Loading