From cdad2b6cabe7dd43a614867e83e8d1ae6743f397 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Dec 2025 17:05:49 +0100 Subject: [PATCH 1/4] Use ValidationCache interface from webonyx/graphql-php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the ValidationCache interface from webonyx/graphql-php#1730 to improve validation result caching with automatic cache invalidation. Cache key now includes: - Library versions (webonyx/graphql-php and nuwave/lighthouse) - Schema hash - Query hash - Rule configuration hash (max_query_depth, disable_introspection) This eliminates the need for manual cache clearing when upgrading graphql-php or lighthouse, as the cache auto-invalidates on version changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/master/performance/query-caching.md | 15 ++- src/Execution/LighthouseValidationCache.php | 38 +++++++ src/GraphQL.php | 29 ++---- tests/Integration/ValidationCachingTest.php | 106 ++++++++++++++++++++ 4 files changed, 167 insertions(+), 21 deletions(-) create mode 100644 src/Execution/LighthouseValidationCache.php diff --git a/docs/master/performance/query-caching.md b/docs/master/performance/query-caching.md index e3efc12ab9..f1d91dd589 100644 --- a/docs/master/performance/query-caching.md +++ b/docs/master/performance/query-caching.md @@ -53,7 +53,6 @@ php artisan lighthouse:clear-query-cache Other reasons to clear the query cache completely include: -- you plan to upgrade the package `webonyx/graphql-php` to a new version that changes the internal representation of parsed queries - you have stale queries in your cache that have an inappropriate or missing TTL - you want to free up disk space used by cached query files @@ -68,11 +67,23 @@ APQ is enabled by default, but depends on query caching being enabled. Lighthouse can cache the result of the query validation process as well. It only caches queries without errors. -`QueryComplexity` validation can not be cached as it is dependent on the query, so it is always executed. +`QueryComplexity` validation can not be cached as it depends on runtime variables, so it is always executed. Query validation caching is disabled by default. You can enable it by setting `validation_cache.enable` to `true` in `config/lighthouse.php`. +### Cache key components + +The validation cache key includes: + +- Library versions (`webonyx/graphql-php` and `nuwave/lighthouse`) - cache is automatically invalidated when upgrading +- Schema hash - cache is invalidated when the schema changes +- Query hash - each unique query has its own cache entry +- Rule configuration hash (`max_query_depth`, `disable_introspection`) - cache is invalidated when security settings change + +This ensures that cached validation results are automatically invalidated when any of the inputs that affect validation change. +You do not need to manually clear the cache when upgrading these libraries. + ## Testing caveats If you are mocking Laravel cache classes like `Illuminate\Support\Facades\Cache` or `Illuminate\Cache\Repository` and asserting expectations in your unit tests, it might be best to disable the query cache in your `phpunit.xml`: diff --git a/src/Execution/LighthouseValidationCache.php b/src/Execution/LighthouseValidationCache.php new file mode 100644 index 0000000000..a12f4898cf --- /dev/null +++ b/src/Execution/LighthouseValidationCache.php @@ -0,0 +1,38 @@ +cache->has($this->cacheKey()); + } + + public function markValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): void + { + $this->cache->put($this->cacheKey(), true, $this->ttl); + } + + private function cacheKey(): string + { + $versions = (InstalledVersions::getVersion('webonyx/graphql-php') ?? '') + . (InstalledVersions::getVersion('nuwave/lighthouse') ?? ''); + + return "lighthouse:validation:{$this->schemaHash}:{$this->queryHash}:{$this->rulesConfigHash}:" . hash('sha256', $versions); + } +} diff --git a/src/GraphQL.php b/src/GraphQL.php index 9cd758f94e..f2052f0e64 100644 --- a/src/GraphQL.php +++ b/src/GraphQL.php @@ -31,6 +31,7 @@ use Nuwave\Lighthouse\Execution\BatchLoader\BatchLoaderRegistry; use Nuwave\Lighthouse\Execution\CacheableValidationRulesProvider; use Nuwave\Lighthouse\Execution\ErrorPool; +use Nuwave\Lighthouse\Execution\LighthouseValidationCache; use Nuwave\Lighthouse\Schema\SchemaBuilder; use Nuwave\Lighthouse\Schema\Values\FieldValue; use Nuwave\Lighthouse\Support\Contracts\GraphQLContext; @@ -396,36 +397,26 @@ protected function validateCacheableRules( } if ($queryHash === null) { - return DocumentValidator::validate($schema, $query, $validationRules); // @phpstan-ignore return.type (TODO remove ignore when requiring a newer version of webonyx/graphql-php) + return DocumentValidator::validate($schema, $query, $validationRules); } $validationCacheConfig = $this->configRepository->get('lighthouse.validation_cache'); if (! $validationCacheConfig['enable']) { - return DocumentValidator::validate($schema, $query, $validationRules); // @phpstan-ignore return.type (TODO remove ignore when requiring a newer version of webonyx/graphql-php) + return DocumentValidator::validate($schema, $query, $validationRules); } $cacheFactory = Container::getInstance()->make(CacheFactory::class); $store = $cacheFactory->store($validationCacheConfig['store']); - $cacheKey = "lighthouse:validation:{$schemaHash}:{$queryHash}"; + // Compute a hash of rule configurations that affect validation behavior + $rulesConfigHash = hash('sha256', json_encode([ + 'max_query_depth' => $this->configRepository->get('lighthouse.security.max_query_depth', 0), + 'disable_introspection' => $this->configRepository->get('lighthouse.security.disable_introspection', 0), + ]) ?: ''); - $cachedResult = $store->get($cacheKey); - if ($cachedResult !== null) { - return $cachedResult; - } - - $result = DocumentValidator::validate($schema, $query, $validationRules); - - // If there are any errors, we return them without caching them. - // As of webonyx/graphql-php 15.14.0, GraphQL\Error\Error is not serializable. - // We would have to figure out how to serialize them properly to cache them. - if ($result !== []) { - return $result; // @phpstan-ignore return.type (TODO remove ignore when requiring a newer version of webonyx/graphql-php) - } - - $store->put($cacheKey, $result, $validationCacheConfig['ttl']); + $cache = new LighthouseValidationCache($store, $schemaHash, $queryHash, $rulesConfigHash, $validationCacheConfig['ttl']); - return $result; + return DocumentValidator::validate($schema, $query, $validationRules, null, $cache); } } diff --git a/tests/Integration/ValidationCachingTest.php b/tests/Integration/ValidationCachingTest.php index 7fb0abe417..326c9485d4 100644 --- a/tests/Integration/ValidationCachingTest.php +++ b/tests/Integration/ValidationCachingTest.php @@ -225,4 +225,110 @@ public function testDifferentSchemasHasDifferentKeys(): void $event->assertDispatchedTimes(CacheHit::class, 0); $event->assertDispatchedTimes(KeyWritten::class, 1); } + + public function testDifferentMaxQueryDepthHasDifferentKeys(): void + { + $config = $this->app->make(ConfigRepository::class); + $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache.enable', true); + $config->set('lighthouse.security.max_query_depth', 10); + + $event = Event::fake(); + + $this->graphQL(/** @lang GraphQL */ ' + { + foo + } + ')->assertExactJson([ + 'data' => [ + 'foo' => Foo::THE_ANSWER, + ], + ]); + + $event->assertDispatchedTimes(CacheMissed::class, 1); + $event->assertDispatchedTimes(CacheHit::class, 0); + $event->assertDispatchedTimes(KeyWritten::class, 1); + + // refresh container, but keep the same cache + $cacheFactory = $this->app->make(CacheFactory::class); + $this->refreshApplication(); + $this->setUp(); + + $this->app->instance(EventsDispatcher::class, $event); + $this->app->instance(CacheFactory::class, $cacheFactory); + + // Change the max_query_depth configuration + $config = $this->app->make(ConfigRepository::class); + $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache.enable', true); + $config->set('lighthouse.security.max_query_depth', 20); + + // Same query should miss because config changed + $this->graphQL(/** @lang GraphQL */ ' + { + foo + } + ')->assertExactJson([ + 'data' => [ + 'foo' => Foo::THE_ANSWER, + ], + ]); + + $event->assertDispatchedTimes(CacheMissed::class, 2); + $event->assertDispatchedTimes(CacheHit::class, 0); + $event->assertDispatchedTimes(KeyWritten::class, 2); + } + + public function testDifferentDisableIntrospectionHasDifferentKeys(): void + { + $config = $this->app->make(ConfigRepository::class); + $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache.enable', true); + $config->set('lighthouse.security.disable_introspection', 0); + + $event = Event::fake(); + + $this->graphQL(/** @lang GraphQL */ ' + { + foo + } + ')->assertExactJson([ + 'data' => [ + 'foo' => Foo::THE_ANSWER, + ], + ]); + + $event->assertDispatchedTimes(CacheMissed::class, 1); + $event->assertDispatchedTimes(CacheHit::class, 0); + $event->assertDispatchedTimes(KeyWritten::class, 1); + + // refresh container, but keep the same cache + $cacheFactory = $this->app->make(CacheFactory::class); + $this->refreshApplication(); + $this->setUp(); + + $this->app->instance(EventsDispatcher::class, $event); + $this->app->instance(CacheFactory::class, $cacheFactory); + + // Change the disable_introspection configuration + $config = $this->app->make(ConfigRepository::class); + $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache.enable', true); + $config->set('lighthouse.security.disable_introspection', 1); + + // Same query should miss because config changed + $this->graphQL(/** @lang GraphQL */ ' + { + foo + } + ')->assertExactJson([ + 'data' => [ + 'foo' => Foo::THE_ANSWER, + ], + ]); + + $event->assertDispatchedTimes(CacheMissed::class, 2); + $event->assertDispatchedTimes(CacheHit::class, 0); + $event->assertDispatchedTimes(KeyWritten::class, 2); + } } From 1308e8ad966b0a1981650d020727b702d3a858df Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Dec 2025 17:29:21 +0100 Subject: [PATCH 2/4] Require webonyx/graphql-php validation-cache branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporarily depend on the validation-cache branch from webonyx/graphql-php to test the ValidationCache interface integration. This will be updated to a proper version constraint once webonyx/graphql-php#1730 is merged and released. Note: This will require a major version bump for Lighthouse. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- composer.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 85b6ca3f4d..61a231a430 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,12 @@ "laravel", "laravel-graphql" ], + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/webonyx/graphql-php.git" + } + ], "authors": [ { "name": "Christopher Moore", @@ -40,7 +46,7 @@ "illuminate/validation": "^9 || ^10 || ^11 || ^12", "laragraph/utils": "^1.5 || ^2", "thecodingmachine/safe": "^1 || ^2 || ^3", - "webonyx/graphql-php": "^15" + "webonyx/graphql-php": "dev-validation-cache as 15.x-dev" }, "require-dev": { "algolia/algoliasearch-client-php": "^3", From d47b2c9bf6bd8c6ec3101dc8b99084c7afa6cc2d Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Dec 2025 09:40:28 +0100 Subject: [PATCH 3/4] implement --- composer.json | 13 ++++---- src/Execution/LighthouseValidationCache.php | 34 ++++++++++++++++++--- src/GraphQL.php | 21 ++++++++++--- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/composer.json b/composer.json index 61a231a430..86d36c5064 100644 --- a/composer.json +++ b/composer.json @@ -8,12 +8,6 @@ "laravel", "laravel-graphql" ], - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/webonyx/graphql-php.git" - } - ], "authors": [ { "name": "Christopher Moore", @@ -34,6 +28,7 @@ "require": { "php": "^8", "ext-json": "*", + "composer-runtime-api": "^2", "haydenpierce/class-finder": "^0.4 || ^0.5", "illuminate/auth": "^9 || ^10 || ^11 || ^12", "illuminate/bus": "^9 || ^10 || ^11 || ^12", @@ -85,6 +80,12 @@ "mll-lab/laravel-graphiql": "A graphical interactive in-browser GraphQL IDE - integrated with Laravel", "pusher/pusher-php-server": "Required when using the Pusher Subscriptions driver" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/webonyx/graphql-php.git" + } + ], "minimum-stability": "dev", "prefer-stable": true, "autoload": { diff --git a/src/Execution/LighthouseValidationCache.php b/src/Execution/LighthouseValidationCache.php index a12f4898cf..3f965ce1b7 100644 --- a/src/Execution/LighthouseValidationCache.php +++ b/src/Execution/LighthouseValidationCache.php @@ -10,6 +10,14 @@ class LighthouseValidationCache implements ValidationCache { + /** @var array */ + protected const RELEVANT_PACKAGES = [ + 'nuwave/lighthouse', + 'webonyx/graphql-php', + ]; + + protected string $packagesHash; + public function __construct( protected CacheRepository $cache, protected string $schemaHash, @@ -28,11 +36,29 @@ public function markValidated(Schema $schema, DocumentNode $ast, ?array $rules = $this->cache->put($this->cacheKey(), true, $this->ttl); } - private function cacheKey(): string + protected function cacheKey(): string + { + return "lighthouse:validation:{$this->schemaHash}:{$this->queryHash}:{$this->rulesConfigHash}:{$this->packagesHash()}"; + } + + protected function packagesHash(): string { - $versions = (InstalledVersions::getVersion('webonyx/graphql-php') ?? '') - . (InstalledVersions::getVersion('nuwave/lighthouse') ?? ''); + return $this->packagesHash ??= $this->buildPackagesHash(); + } - return "lighthouse:validation:{$this->schemaHash}:{$this->queryHash}:{$this->rulesConfigHash}:" . hash('sha256', $versions); + protected function buildPackagesHash(): string + { + $versions = []; + foreach (self::RELEVANT_PACKAGES as $package) { + $versions[$package] = $this->requireVersion($package); + } + + return hash('sha256', \Safe\json_encode($versions)); + } + + protected function requireVersion(string $package): string + { + return InstalledVersions::getVersion($package) + ?? throw new \RuntimeException("Could not determine version of {$package} package."); } } diff --git a/src/GraphQL.php b/src/GraphQL.php index f2052f0e64..17af358fcd 100644 --- a/src/GraphQL.php +++ b/src/GraphQL.php @@ -410,13 +410,24 @@ protected function validateCacheableRules( $store = $cacheFactory->store($validationCacheConfig['store']); // Compute a hash of rule configurations that affect validation behavior - $rulesConfigHash = hash('sha256', json_encode([ + $rulesConfigHash = hash('sha256', \Safe\json_encode([ 'max_query_depth' => $this->configRepository->get('lighthouse.security.max_query_depth', 0), 'disable_introspection' => $this->configRepository->get('lighthouse.security.disable_introspection', 0), - ]) ?: ''); - - $cache = new LighthouseValidationCache($store, $schemaHash, $queryHash, $rulesConfigHash, $validationCacheConfig['ttl']); + ])); + + $cache = new LighthouseValidationCache( + cache: $store, + schemaHash: $schemaHash, + queryHash: $queryHash, + rulesConfigHash: $rulesConfigHash, + ttl: $validationCacheConfig['ttl'], + ); - return DocumentValidator::validate($schema, $query, $validationRules, null, $cache); + return DocumentValidator::validate( + schema: $schema, + ast: $query, + rules: $validationRules, + cache: $cache, + ); } } From b47e5eeb95cc49417aef08ac06f2ce45bb550a65 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Dec 2025 09:51:21 +0100 Subject: [PATCH 4/4] clean up implementation --- src/GraphQL.php | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/GraphQL.php b/src/GraphQL.php index 17af358fcd..f1cde3d037 100644 --- a/src/GraphQL.php +++ b/src/GraphQL.php @@ -35,6 +35,7 @@ use Nuwave\Lighthouse\Schema\SchemaBuilder; use Nuwave\Lighthouse\Schema\Values\FieldValue; use Nuwave\Lighthouse\Support\Contracts\GraphQLContext; +use Nuwave\Lighthouse\Support\Contracts\ProvidesCacheableValidationRules; use Nuwave\Lighthouse\Support\Contracts\ProvidesValidationRules; use Nuwave\Lighthouse\Support\Utils as LighthouseUtils; @@ -139,7 +140,7 @@ public function executeParsedQueryRaw( new StartExecution($schema, $query, $variables, $operationName, $context), ); - if ($this->providesValidationRules instanceof CacheableValidationRulesProvider) { + if ($this->providesValidationRules instanceof ProvidesCacheableValidationRules) { $cacheableValidationRules = $this->providesValidationRules->cacheableValidationRules(); $errors = $this->validateCacheableRules($cacheableValidationRules, $schema, $this->schemaBuilder->schemaHash(), $query, $queryHash); @@ -407,27 +408,24 @@ protected function validateCacheableRules( } $cacheFactory = Container::getInstance()->make(CacheFactory::class); - $store = $cacheFactory->store($validationCacheConfig['store']); + $cacheStore = $cacheFactory->store($validationCacheConfig['cacheStore']); - // Compute a hash of rule configurations that affect validation behavior $rulesConfigHash = hash('sha256', \Safe\json_encode([ 'max_query_depth' => $this->configRepository->get('lighthouse.security.max_query_depth', 0), 'disable_introspection' => $this->configRepository->get('lighthouse.security.disable_introspection', 0), ])); - $cache = new LighthouseValidationCache( - cache: $store, - schemaHash: $schemaHash, - queryHash: $queryHash, - rulesConfigHash: $rulesConfigHash, - ttl: $validationCacheConfig['ttl'], - ); - return DocumentValidator::validate( schema: $schema, ast: $query, rules: $validationRules, - cache: $cache, + cache: new LighthouseValidationCache( + cache: $cacheStore, + schemaHash: $schemaHash, + queryHash: $queryHash, + rulesConfigHash: $rulesConfigHash, + ttl: $validationCacheConfig['ttl'], + ), ); } }