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 all 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
132 changes: 132 additions & 0 deletions docs/executing-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,135 @@ $server = new StandardServer([
'validationRules' => $myValidationRules
]);
```

## Validation Caching

Validation is a required step in GraphQL execution, but it can become a performance bottleneck.
In production environments, queries are often static or pre-generated (e.g. persisted queries or queries emitted by client libraries).
This means that many queries will be identical and their validation results can be reused.

To optimize for this, `graphql-php` allows skipping validation for known valid queries.
Leverage pluggable validation caching by passing an implementation of the `GraphQL\Validator\ValidationCache` interface to `GraphQL::executeQuery()`:

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

$validationCache = new MyPsrValidationCacheAdapter();

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

### Key Generation Tips

You are responsible for generating cache keys that are unique and dependent on the following inputs:

- the client-given query
- the current schema
- the passed validation rules and their implementation
- the implementation of `graphql-php`

Here are some tips:

- Using `serialize()` directly on the schema object may error due to closures or circular references.
Instead, use `GraphQL\Utils\SchemaPrinter::doPrint($schema)` to get a stable string representation of the schema.
- If using custom validation rules, be sure to account for them in your key (e.g., by serializing or listing their class names and versioning them).
- Include the version number of the `webonyx/graphql-php` package to account for implementation changes in the library.
- Use a stable hash function like `md5()` or `sha256()` to generate the key from the schema, AST, and rules.
- Improve performance even further by hashing inputs known before deploying such as the schema or the installed package version.
You may store the hash in an environment variable or a constant to avoid recalculating it on every request.

### 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 MyPsrValidationCacheAdapter 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
{
// Include package version to account for implementation changes
$libraryVersion = \Composer\InstalledVersions::getVersion('webonyx/graphql-php')
?? throw new \RuntimeException('webonyx/graphql-php version not found. Ensure the package is installed.');

// Use a stable hash for the schema
$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));

return "graphql_validation_{$libraryVersion}_{$schemaHash}_{$astHash}_{$rulesHash}";
}
}
```

An optimized version of `buildKey` might leverage a key prefix for inputs known before deployment.
For example, you may run the following once during deployment and save the output in an environment variable `GRAPHQL_VALIDATION_KEY_PREFIX`:

```php
$libraryVersion = \Composer\InstalledVersions::getVersion('webonyx/graphql-php')
?? throw new \RuntimeException('webonyx/graphql-php version not found. Ensure the package is installed.');

$schemaHash = md5(SchemaPrinter::doPrint($schema));

echo "{$libraryVersion}_{$schemaHash}";
```

Then use the environment variable in your key generation:

```php
private function buildKey(Schema $schema, DocumentNode $ast, ?array $rules = null): string
{
$keyPrefix = getenv('GRAPHQL_VALIDATION_KEY_PREFIX')
?? throw new \RuntimeException('Environment variable GRAPHQL_VALIDATION_KEY_PREFIX is not set.');
$astHash = md5(serialize($ast));
$rulesHash = md5(serialize($rules));

return "graphql_validation_{$keyPrefix}_{$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
46 changes: 46 additions & 0 deletions src/Validator/ValidationCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php declare(strict_types=1);

namespace GraphQL\Validator;

use GraphQL\Language\AST\DocumentNode;
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()` function 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.
*/
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