Skip to content

Support per-file result cache dependencies - #6300

Draft
jbboehr wants to merge 1 commit into
phpstan:2.2.xfrom
jbboehr:prototype/result-cache-dependencies
Draft

Support per-file result cache dependencies#6300
jbboehr wants to merge 1 commit into
phpstan:2.2.xfrom
jbboehr:prototype/result-cache-dependencies

Conversation

@jbboehr

@jbboehr jbboehr commented Aug 29, 2026

Copy link
Copy Markdown

Summary

This adds an extension point for attaching extension-defined semantic dependencies to the result of analysing a file.

An extension provides a globally unique, stable provider key. Implementations should normally return self::class; the explicit key also allows multiple configured instances of the same class to have separate identities. A rule records that a file used an opaque (provider key, dependency key) pair through ResultCacheDependencyCollector. When saving or restoring the result cache, PHPStan asks the registered provider to calculate the hash for that dependency. On restore, PHPStan compares the stored and current hashes and reanalyses the files that emitted a changed dependency.

The main pieces are:

  • ResultCacheDependencyExtension, registered with the phpstan.resultCacheDependencyExtension tag;
  • ResultCacheDependencyCollector::createData(), emitted through CollectedDataEmitter from a rule;
  • result-cache handling that stores the hashes, checks them on restore, and fails closed by reanalysing the affected file when a cached record is malformed or names an unknown provider.

The collector is only a marker for data emitted by rules. It is not registered as a normal collector.

Motivation

ResultCacheMetaExtension is deliberately global: when its hash changes, the whole result cache is invalidated. That is safe, but expensive for extensions backed by a large metadata source when a file only depends on one small part of it.

External file tracking, as proposed in phpstan/phpstan-src#5364, narrows invalidation when the state is naturally represented by separate files. It does not help as much when separate semantic values are stored in one generated object or derived from several source files. Examples include the inferred validation shape of an individual FormRequest or one service/parameter in a Symfony container.

This follows the special-collector direction discussed on that PR, using CollectedDataEmitter to record per-file dependencies.

Invalidation contract

A result-cache dependency represents analysis state consumed directly by a particular analysed file. It does not represent state that changes that file's exported PHP API.

Only files that emitted a changed dependency are reanalysed. Unlike the external-file proposal in phpstan/phpstan-src#5364, their ordinary PHPStan dependants are not added automatically.

This means every file whose analysis result can be affected by the semantic state has to emit its own dependency. If an extension cannot guarantee that—for example because the state has global or indirect effects—it should continue using ResultCacheMetaExtension.

Dependencies must be emitted from Rule::processNode(). If another extension callback, such as a dynamic return-type extension, consumes the semantic state, a companion rule must recognize that use and emit the same dependency. Other extension callbacks can receive scopes without an active collected-data callback.

Dependency keys can come from an older result cache, so getHash() implementations also need to handle missing or obsolete keys deterministically. The API does not promise a particular call count, order, or process context. Restore can call getHash() before configured bootstrapFiles run, while save can call it afterwards, so the hash cannot depend on application state initialized by those files.

Malformed cached records and unknown provider keys reanalyse the affected file. Duplicate provider keys and exceptions thrown by getHash() abort the analysis instead of being treated as cache misses.

Hashes are cache metadata only. A hash supplied on a newly emitted record is discarded and recomputed through the registered provider. Repeated emissions of the same provider/key pair are stored once per file. Hashes are removed before collected data is exposed to CollectedDataNode rules or returned in the final analysis result, so extensions see the same record shape on cold and warm runs.

Prototypes

The phpstan-laravel-validation prototype uses the concrete FormRequest class as the dependency key. Changing one request's validation rules reanalyses that request's consumers without invalidating consumers of unrelated requests.

The opt-in phpstan-symfony prototype uses service IDs and parameter names as dependency keys, while keeping the existing global cache metadata extension enabled by default. It excludes Messenger because that return-type map also depends on handler reflection unavailable during pre-bootstrap restore; the broader safety concern in phpstan/phpstan-symfony#455 still applies. For comparison, phpstan/phpstan-symfony#478 uses the earlier external-file proposal.

Tests

The end-to-end fixture models a dynamic return-type extension backed by per-key configuration types. Coverage includes selective invalidation, malformed and obsolete records, provider isolation, deduplication, untrusted emitted hashes, and cold, warm, and partial cache runs.

I also built the PHAR and ran the external-extension fixture against it:

cd e2e/result-cache-dependency
composer install --no-interaction --no-progress
../../tmp/phpstan.phar clear-result-cache
../../tmp/phpstan.phar analyse --no-progress
../../tmp/phpstan.phar analyse --no-progress --fail-without-result-cache

This verifies that the fixture's rule loads with the public Scope method signature and that the result cache is restored on the second run.

I also ran the full PHPUnit suite, self-analysis, PHP_CodeSniffer, and the new end-to-end scenario locally.

Benchmark

I compared a provider emitting no records with one emitting the same shared dependency key from each of 10,000 analysed files. These are 12 alternating warm runs after separate cold-cache primes on PHP 8.3.33; both configurations restored the cache with 0 files reanalysed.

No records 10,000 records
Median wall time 0.37 s 0.42 s
Range 0.36–0.37 s 0.39–0.43 s
Median peak RSS 133.2 MiB 150.8 MiB
Result-cache size 9.24 MiB 12.52 MiB

The 10,000 records added about 50 ms, 17.6 MiB of peak RSS, and 3.28 MiB to the cache (about 344 bytes per record). All records share one key, so getHash() is memoized once; this isolates record storage and restore traversal rather than provider-specific hash cost, high-cardinality keys, or the savings from narrower invalidation.

Questions for review

  • Is emitting dependencies from a companion Rule through CollectedDataEmitter an acceptable API? Semantic state is often consumed by another extension callback, such as a dynamic return-type extension, so the Rule has to recognize the same use independently.
  • Is requiring getHash() to be independent of bootstrapFiles an acceptable limitation, or should providers be able to create a post-bootstrap snapshot that can be checked during cache restore?
  • Is a provider key plus an opaque dependency key the right level of abstraction?
  • Should invalidation remain limited to files that emit the dependency, or are there cases where PHPStan should also add their ordinary dependants?
  • Are ResultCacheDependencyExtension and ResultCacheDependencyCollector reasonable names, given that the collector is used only as an emitted-data marker?

Allow extensions to associate semantic dependency keys with individual analysed files while calculating hashes in the main process during cache save and restore. This keeps invalidation narrow without making worker results depend on mutable external state.

Fail closed for malformed records, stop reading dependency keys once a file is scheduled, and keep persisted hashes out of extension-visible collected data. Document the provider and emission lifecycle contracts and cover the public API and incremental cache behaviour end to end.
@jbboehr
jbboehr force-pushed the prototype/result-cache-dependencies branch from 5c45e9a to a88dcc3 Compare August 29, 2026 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant