Skip to content

Store the result cache in a framed serialize() format instead of a var_export'd PHP file - #5982

Merged
ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
SanderMuller:result-cache-serialize
Sep 3, 2026
Merged

Store the result cache in a framed serialize() format instead of a var_export'd PHP file#5982
ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
SanderMuller:result-cache-serialize

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What

The result cache is a var_export'd PHP file hydrated with include. Including a multi-megabyte PHP source retains its compiled op_arrays and interned strings, and where OPCache is on it also occupies shared memory. This writes the file as framed serialize() payloads instead.

Format: <?php return; ?> on the first line, then name length or name* count headers, each followed by length-prefixed serialized payloads. Written frame by frame, and the array sections entry by entry, because serializing a section in one call holds it in memory twice over - exportedNodes alone is 39 MB on the larger project below. The read side is framed for the same reason.

errors, locallyIgnoredErrors, collectedData and exportedNodes are the sections restore() hands back as callbacks. The var_export format defers them by writing one closure per section, which still leaves PHP compiling the array literal inside it. Here each section is a single frame, so the reader remembers where it starts, walks past its entries without unserializing them, and decodes on demand.

What loading the cache costs

Isolated, same 5400 entries, both formats read with OPCache enabled:

var_export framed
open the cache 0.39s 0.01s
heap after opening 325.0 MB 14.0 MB
heap with all four lazy sections materialised 325.0 MB 124.0 MB
OPCache shared memory 39.9 MB, one 41,739,216 byte script entry none

Default opcache.memory_consumption is 128 MB, so on this project the cache file alone claims a third of the buffer, leaving that much less for PHPStan's own code; a project three times the size does not fit in it at all.

End to end

Warm peak of the main process, interleaved A/B, every figure repeated across rounds:

project base this change
4524 files, level 5 357.7 MB 140.0 MB -61%
2485 files, level 5 220.4 MB 96.0 MB -56%

Cold main-process peak on the first: 120 MB against 92 MB, worker peak flat (334.1 against 336.1 MB). Cold and warm wall are unchanged or slightly better.

One exception worth naming: analysing this repository with its own phpstan.neon.dist (bleeding edge, strict rules, collectors) the warm peak goes the other way, 339.05 to 346.52 MB. There the peak is set by collector processing rather than by the restore, and I have not chased the remaining 7 MB.

The file on disk is bigger: +11.9% at 2485 files, +34.7% at 4524.

Truncated caches

The separable half of this is now #6349, which renames the cache into place so a killed run stops leaving a partial file at the path the next run reads, in either format.

The guard here stays, because a format that can be read halfway has to refuse to: every frame violation throws and restore() discards the file, which is what the var_export format got for free from a ParseError. Two e2e cases cover it, one cutting inside a lazily-read section - fseek() past the end of a file succeeds, so that one is only caught by comparing the position with the file size.

Transition

No cache version bump is needed. A cache in the old PHP format fails to unserialize and is discarded like any other unreadable file.

@ondrejmirtes

Copy link
Copy Markdown
Member

I just merged #5981, please try out latest 2.2.x-dev on real-world projects. Please note this needs bleeding edge enabled.

@SanderMuller

Copy link
Copy Markdown
Contributor Author

2.2.4's streaming save (ee9fe9e) addresses the save-side half of this: the peak from building the whole var_export string in memory is gone. This PR overlaps that half, so it needs rebasing, and the two approaches are mutually exclusive (the read format has to match the write format).

Where they differ is the read side. restore() still includes the var_export'd file, which retains the file's compiled op_arrays and interned strings for the process lifetime. unserialize() produces only the values, so that retention goes away. That read-side saving is independent of the streaming change (which only touched save()): the warm-run main-process peak drop I measured (about -21% on a large doctrine/symfony project, similar on a large Laravel one) is entirely this effect and still applies on 2.2.4.

So this is really a format choice: keep var_export + include (streamed on write), or switch to serialize + unserialize (lower read-side retention, no streaming needed on write since there is no giant string to build). It is your call which direction you prefer for this file, especially given you are actively working on it.

If you want to pursue the serialize direction I will rebase onto 2.2.4 and re-measure both sides on current code; if you would rather keep the var_export format, I will close this. The transition either way is safe without a cache-version bump (old and new formats each fall through the existing corrupted-cache path), and the downgrade case is handled by prefixing the payload so an older PHPStan does not echo it.

@staabm

staabm commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

#5981 was reverted meanwhile, as it did not improve much and having more than 1 cache file might make trouble in exisiting setups which do not persist the whole temp-folder but just the single result cache file we have today

@SanderMuller
SanderMuller force-pushed the result-cache-serialize branch from ee5f26c to 80d339d Compare July 4, 2026 06:56
@SanderMuller

Copy link
Copy Markdown
Contributor Author

Thanks for the heads-up on #5981. Worth clarifying how this PR relates, since it's a different change: it keeps the single result cache file exactly as today, and only changes that one file's format from a var_export'd PHP file to serialize(). So it doesn't introduce the multi-file behaviour that reverted #5981, and setups that persist just the single result cache file keep working unchanged. I've rebased it onto current 2.2.x, so it's no longer conflicting.

The motivation is the read side. On current 2.2.x restore() still does $data = require $cacheFilePath;, and requiring a multi-megabyte PHP file keeps its compiled op_arrays and interned strings resident for the whole process; unserialize() produces just the values, so that retention goes away.

The trade-off against 2.2.4's streaming save: serialize() builds the whole string in memory before writing, giving up the streaming that keeps the save-side peak low. Whether that nets out positive on the overall peak depends on whether the analysis peak or the save peak dominates for a given project, so I don't want to lean on my earlier figure (it was measured against the pre-streaming baseline). I'm happy to run a fresh before/after on a large project against current 2.2.x so there's a real number to decide on, or to close this if the format is settled for now. Whichever you prefer.

@SanderMuller
SanderMuller force-pushed the result-cache-serialize branch from 80d339d to c13bc67 Compare July 4, 2026 20:06
@SanderMuller
SanderMuller force-pushed the result-cache-serialize branch from c13bc67 to a8151ff Compare August 12, 2026 20:06
@SanderMuller

Copy link
Copy Markdown
Contributor Author

Rebased onto current 2.2.x — it had drifted 334 commits behind, so the CI signal on it was meaningless. Applied cleanly, no conflicts, diff unchanged at +44/-94 in one file.

Re-verified rather than assuming the rebase was inert:

  • Full suite green (21306), self-analysis clean, phpcs clean.
  • Real cold/warm cycle on a scratch project: the cache is written as <?php return; ?>a:11:{...} and the warm run reuses it (--fail-without-result-cache exits 0).
  • The downgrade guard the SERIALIZED_FILE_PREFIX comment claims actually holds: includeing the cache file from an older PHPStan returns NULL and echoes 0 bytes, so a downgrade degrades to a silent full analysis rather than dumping megabytes to stdout.

The perf numbers in the description are from early July and I have not re-measured them on current 2.2.x; say the word if you want a fresh set before reviewing.

Heads-up on an overlap: #6190 also rewrites paths inside ResultCacheManager. Whichever lands first, the other needs a rebase — happy to sequence them however you prefer.

@staabm

staabm commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

after php/php-src#23189 landed, we should remeasure

@SanderMuller
SanderMuller force-pushed the result-cache-serialize branch from a8151ff to c27d505 Compare August 19, 2026 08:47
@SanderMuller SanderMuller changed the title Store the result cache with serialize() instead of a var_export'd PHP file Store the result cache in a framed serialize() format instead of a var_export'd PHP file Aug 19, 2026
Comment thread e2e/result-cache-truncated/truncate.php Outdated
Comment on lines +3 to +11
// A process killed while the result cache is being written leaves a partial file at the final
// path, because the cache is streamed there rather than written atomically. Cutting the file
// inside the first section's payload is the shape that used to be read as a half-populated
// cache instead of a damaged one.
$file = __DIR__ . '/tmp/resultCache.php';
$contents = file_get_contents($file);
if ($contents === false) {
throw new RuntimeException('No result cache at ' . $file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is this a bug/case independent of the format change and needs a separate PR?

@SanderMuller

Copy link
Copy Markdown
Contributor Author

@staabm update, and a defect I found while re-checking it.

On the remeasure gate

php/php-src#23189 is still open, so there is nothing to remeasure against yet. Worth knowing how much it can move this decision, though: I built and measured that PR while reviewing it, and it makes var_export() 2.5x-4.6x faster (4.18x on a flat 2000-element array, 2.51x on a 140 KB string needing escapes throughout). That is the base side's save path.

This PR's pitch is the read side - includeing the cache file retains its compiled op_arrays and interned strings for the process lifetime, unserialize() produces only the values - which 23189 does not touch. So it can move the CPU column for base's save, not the memory column that is the reason for the change. It also targets master, so PHP 8.6 at the earliest, while PHPStan runs on 7.4-8.5 today.

I am happy to remeasure whenever you want, either when it lands or against a locally patched build. Say which and I will run it.

A defect, now fixed

Re-checking the format handling turned up a real regression against base. The cache file is streamed to its final path, so a process killed during the save leaves a truncated file. With the framed reader, a truncation inside a value frame was read as far as it went and the missing frames came back as null/false; is_array($data) then passed and the first missing section surfaced far from the cause:

[TypeError] ResultCacheManager::isMetaDifferent(): Argument #1 ($cachedMeta)
must be of type array, false given, called in ResultCacheManager.php on line 289

Base degrades gracefully for the same corruption, because an incomplete var_export'd file is a ParseError that restore() already catches:

Result cache not used because an error occurred while loading the cache file: Unclosed '(' on line 111

Every format violation now throws, so the file lands in that same handler. Eight truncation points from 100 bytes to 99.5% of the file all report, for example, Result cache not used because an error occurred while loading the cache file: Expected a 47693 byte frame, read 4935 bytes. and continue with a full analysis, while an intact cache still restores.

e2e/result-cache-truncated pins it: with the fix the second run exits 0, without it exit 1 with the TypeError above.

Re-verified rather than assumed, on current 2.2.x

  • Upgrade path: an old var_export cache read by this branch is discarded as corrupted, then a full analysis.
  • Downgrade path: this branch's cache read by 2.2.x is discarded as corrupted with 0 serialized bytes reaching stdout, so the <?php return; ?> guard does what its comment claims.
  • Output identical between base and this branch, cold and warm - 102 error lines each on a src/Rules + src/Type/Php run, sorted diff clean, and the warm run confirmed as restoring rather than reanalysing.
  • Full suite 21323 tests, self-analysis clean, phpcs clean on the touched file.

What I did not do

I have not re-run the memory and CPU table in the description; those figures predate today's rebase. I deliberately took no timings today because this machine is heavily contended and the numbers would not be worth publishing. That is the one open item, and it is the same one 23189 would want a rerun for anyway.

CI note: the two red Symplify integration jobs before my push failed on a composer 404 for the TomasVotruba/ecs zipball, the same failure I see on an unrelated PR of mine, so not attributable here. A fresh run is in flight for the fix commit.

The #6190 overlap still stands - whichever of the two lands first, the other needs a rebase.

@SanderMuller

Copy link
Copy Markdown
Contributor Author

Followed up on the one open item - here is the fresh measurement, on current 2.2.x and on this branch as it stands.

Setup

A vendor tree built only from public packages, so you can rebuild it: symfony/console, symfony/http-kernel, symfony/dependency-injection, symfony/framework-bundle, symfony/validator, symfony/serializer, symfony/form, symfony/messenger, symfony/mailer (all ^6.4), doctrine/orm ^2.20, doctrine/dbal ^3.9, doctrine/collections ^2.2, twig/twig ^3, monolog/monolog ^3, guzzlehttp/guzzle ^7 - 3862 PHP files, analysed at level 5 with 4 worker processes, 4819 reported errors. Base 9e4cf9d53, this branch de278ebb3. macOS arm64, PHP 8.5.8, 3 interleaved rounds, memory_get_peak_usage(true) of the main process as reported by -vvv. Every figure below repeated identically in all three rounds.

Warm - the case this PR is about

base this branch
peak, pure restore 393.1 MB 208.0 MB -47.1%
peak, OS maxrss 413.9 MB 236.1 MB -43.0%
peak, restore + 1 changed file 393.1 MB 242.0 MB -38.4%
CPU (user), pure restore 1.01 s 0.76 s -24.8%
wall, pure restore 1.29 s 1.03 s -20.2%
wall, restore + 1 changed file 3.03 s 2.77 s -8.6%

The maxrss row is there because a drop in memory_get_peak_usage() is not worth much on its own - the OS-level number moves with it, so this is really less memory and not just less allocator bookkeeping.

Note base's peak is the same 393.1 MB whether it reanalyses a file or not: on base the restore is the peak. On this branch it is not, which is why the changed-file figure sits above the pure-restore one.

Cold - and a correction to the description

base this branch
peak 2232.3 MB 2211.8 MB -0.9%
CPU (user) 153.50 s 153.31 s -0.1%
wall 43.75 s 43.71 s -0.1%

The description's -18% / -21% cold-peak claim does not reproduce here. On this corpus the cold peak is analysis-dominated (2.2 GB against a 60 MB cache), so the save is a rounding error and cold comes out flat. Those older figures came from projects where the cold peak was much closer to the cache size, so the honest version of that claim is "corpus-dependent, and flat where analysis dominates". I would rather say that here than leave a number in the description that does not hold generally. The warm figures, which are the point of the change, hold and are slightly better than what is written there.

Cache on disk: 63,013,832 -> 77,148,189 bytes, +22.4%.

Output identical: 4819 error lines on both, sorted diff clean, both warm runs confirmed as restoring rather than reanalysing.

What this means for the php-src#23189 gate

I instrumented the save phase on both sides:

save phase share of a ~44 s cold run
base, var_export streamed 0.152 s 0.35%
this branch, framed serialize 0.091 s 0.21%

23189 makes var_export 2.5x-4.6x faster, so at best it removes something like 0.1 s from a 44 s run, on PHP 8.6 and later only. It cannot move either column that decides this PR. I would not hold the decision for it - though I am still happy to re-run this table against a patched build if you want it on the record.

Numbers were taken with the machine otherwise idle for this work; the three-round repetition is there because it is a shared machine.

@SanderMuller
SanderMuller requested a review from staabm August 19, 2026 14:34
@staabm

staabm commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

this needs a review from ondrej. my feeling is that this will not land as is.

maybe result-cache handling would see some measurable real world benefits by implementing it in turbo.
(separate PR)

@staabm

staabm commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

the "truncate" part and the format change are 2 separate concerns, which I think should have separate PRs... wdyt?

@ondrejmirtes

Copy link
Copy Markdown
Member

I'm getting warm to this idea. OPCache which we now always enable will have less work and less things to hold in memory if we switch away from the .php format.

@SanderMuller

Copy link
Copy Markdown
Contributor Author

Rebased onto current 2.2.x, and the rebase found a real break: the cache file now carries errors, locallyIgnoredErrors, collectedData and exportedNodes as closures, which my framed reader did not produce. Git merged that cleanly and the result restored zero errors with an Undefined array key "errorsCallback" warning behind it, failing 10 result-cache e2e scenarios. Only running the whole job showed it.

So the frames now decode on demand rather than up front, which the format is a better fit for than a PHP file: each section is one frame, so the reader walks past its entries and seeks back when a callback asks. Eagerly wrapping them instead cost +7.5 MB, so this part is the design rather than an adaptation.

@ondrejmirtes on the OPCache point, measured on the same 5400 entries with OPCache on: including the 41.9 MB var_export cache costs 39.9 MB of shared memory as a single 41,739,216 byte script entry, 0.39s, and 325 MB of heap before any section is asked for. The framed file costs no shared memory at all, 0.01s, and 14 MB of heap, or 124 MB with all four sections materialised. With the 128 MB default opcache.memory_consumption, the cache file alone claims a third of the buffer today.

End to end the warm peak drops 61% on a 4524-file project and 56% on a 2485-file one. The honest exception is this repository's own config, where the peak is collector processing rather than the restore and goes 339 to 346.52 MB. The file on disk is 12 to 35% bigger. All of it is in the description now.

@staabm on splitting: the separable half is up as #6349, which renames the cache into place so a killed save stops leaving a partial file at all, in either format. It stands on its own and I would take it first. The guard here cannot go with it, because it only touches the framed reader this PR introduces and shipping the format without it would regress against the ParseError the old format got for free.

@SanderMuller
SanderMuller force-pushed the result-cache-serialize branch from f49b495 to 85fe784 Compare September 2, 2026 15:24
@ondrejmirtes

Copy link
Copy Markdown
Member

The callbacks had a reason. I suspect if you unserialize the serialized string, everything in it is going to instantiated, right? And it might corrupt the objects if their internal properties meanwhile changed. I don't know how it's going to behave, whether it will crash outright or whether the objects report an error once we start asking things. Please test it.

Also, __set_state on the classes with objects in the result cache is no longer relevant (because it's called when reading the var_exported cache) so make sure to clean that up.

@SanderMuller

Copy link
Copy Markdown
Contributor Author

Tested, and you were right: it does change behaviour, and it needed a fix.

Same fixture, a cache written before the class changed, then read back after:

change to Error var_export + __set_state framed serialize()
rename or remove a property fine, the constructor's default applies aborts the run: Typed property PHPStan\Analyser\Error::$tipRenamed must not be accessed before initialization
append a promoted property with a default fine fine

Appending one without a default breaks the analyser itself, since no Error can be constructed at all, so that shape is not a cache-format question.

So unserialize() does not corrupt or crash on the objects as such: it instantiates them without the constructor, and the failure comes later, when something reads a typed property the payload did not carry. There is no hook to fill it, which is the reason the callbacks had.

cacheVersion and phpstanVersion keep one release away from another release's objects. But phpstanVersion comes from Composer's installed.php, not from the working tree, so a source checkout keeps one value across every edit of these classes - which is exactly where this is reachable, and where PHPStan is developed.

Fixed in the last commit: the four callbacks are invoked inside restore()'s failure path, so a cache that cannot be read back is discarded and everything re-analysed, like any damaged file. The message names the cause. Worth knowing that the same shape aborts the command on 2.2.x as well, uncaught and with a usage dump, because the var_export closures are evaluated at that same point - so that half is not a regression, it is only newly reachable.

On cleaning up __set_state: the table above is the argument for not simply deleting it. It is the only thing that reconstructs through the constructor, so removing it trades that tolerance for "the cache is discarded and re-analysed". Both are defensible now that the discard is clean, and it is your call:

  • remove them, and accept a discarded cache whenever these classes change, or
  • add __unserialize() to the 22 classes that implement __set_state() today, mirroring the same ?? null handling, and keep the tolerance.

Say which one and I will do it in this PR.

@staabm

staabm commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Tested, and you were right: it does change behaviour, and it needed a fix.

would be great to have this covered by a test

@SanderMuller
SanderMuller force-pushed the result-cache-serialize branch from b742cd7 to dca415a Compare September 2, 2026 18:42
@SanderMuller

Copy link
Copy Markdown
Contributor Author

Covered now, and rebased onto current 2.2.x.

e2e/result-cache-stale-objects renames Error::$message inside the cache file, which is the same shape as a cache written by a PHPStan whose classes have changed since, without needing two versions installed. The byte length stays identical so the frame headers remain valid. The run then reports

Result cache not used because the cached results could not be read back:
Typed property PHPStan\Analyser\Error::$message must not be accessed before initialization

re-analyses, and the cache written in its place is usable again. With the change reverted the same fixture dies on the uninitialized property instead, so the test fails for the right reason.

One limit worth stating: the reconstruction is only exercised for the sections whose absolutizing reads the objects' properties, which is errors and locallyIgnoredErrors. A corrupted exportedNodes entry survives the read, because absolutizing those only rewrites the array keys, and it would surface later when the node is compared. I tried that shape first and it restored cleanly, which is how I found the asymmetry.

@ondrejmirtes

Copy link
Copy Markdown
Member

Let's walk back a bit - are you actually sure that changing the Error class declaration making the serialized Error object no longer match the declaration would lead to an error? Because with every new PHPStan version, the result cache is thrown away (PHPStan version is part of getMeta) so if deserialization of stale data succeeds but the Errors are never accessed, it might be okay? I'm asking because I'm not sure how were you able to keep a "callback" in the serialized data, Closures are typically not serializable and it's not obvious to me from the code. Feel free to revert the latest change after some thinking.

@SanderMuller

Copy link
Copy Markdown
Contributor Author

The closures are not in the file, and that is my comment's fault - I have pushed a clearer one. A lazy section is written as plain frames:

a:1:{s:9:"file.php";a:1:{i:0;O:22:"PHPStan\Analyser\Error":13:{...

and readCacheFile() builds the callback in PHP around the open handle and that frame's offset. It is only the shape restore() expects; nothing closure-shaped is serialized. The var_export format is the one that writes a real static function (): array into the file, and PHP still compiles the array literal inside it.

On whether it can actually break: it does, and the Errors are not "never accessed". restore() calls errorsCallback() unconditionally, and the wrapper runs absolutizeErrors(), which rebuilds every cached Error through transformPaths() and therefore reads every property. Renaming Error::$tip and then reading a cache written before the rename aborts the command with Typed property PHPStan\Analyser\Error::$tip must not be accessed before initialization.

But you are right that it is out of reach for a released version: there phpstanVersion is 2.2.12 and a cache from any other release is discarded before anything is reconstructed. It is reachable only from a source checkout, because getPhpStanVersion() reads the root reference out of Composer's installed.php, which does not move when you edit src/ - mine stayed 2.2.x-dev@bba3c00 across every branch I switched to while testing this.

So it protects contributors rather than users. Happy to revert it; that would take e2e/result-cache-stale-objects with it, which @staabm asked for earlier today. Your call, I have no strong preference beyond liking the smaller PR.

@SanderMuller
SanderMuller force-pushed the result-cache-serialize branch from 16f09f6 to b87b09c Compare September 3, 2026 09:32
Sander Muller and others added 2 commits September 3, 2026 21:22
…r_export'd PHP file

The cache is a var_export'd PHP file hydrated with include. Including a
multi-megabyte PHP source retains its compiled op_arrays and interned strings
for the process lifetime, and where OPCache is on it also occupies shared
memory: on a 2485 file project the file claims 39.9 MB of it as a single
41,739,216 byte script entry, against a 128 MB default
opcache.memory_consumption.

The file is now framed serialize() payloads: `<?php return; ?>` on the first
line, then `name length` or `name* count` headers, each followed by
length-prefixed payloads. Written frame by frame, and the array sections entry
by entry, because serializing a section in one call holds it in memory twice
over - exportedNodes alone is 39 MB on a 4524 file project - which is the trap
the var_export writer avoided the same way.

errors, locallyIgnoredErrors, collectedData and exportedNodes are the sections
restore() hands back as callbacks. The var_export format defers them by writing
one closure per section, which still leaves PHP compiling the array literal
inside it. Here each section is one frame, so the reader remembers where it
starts, walks past its entries without unserializing them, and decodes on
demand. Walking rather than seeking wholesale keeps the framing of the whole
file validated up front, so a damaged cache is discarded by restore() rather
than throwing later inside a callback.

Measured, main process, interleaved, every figure repeated across rounds:
opening the cache 0.39s and 325 MB against 0.01s and 14 MB, or 124 MB with all
four lazy sections materialised; warm peak -61% at 4524 files and -56% at 2485.
The file on disk is 12 to 35% bigger.

A format that can be read half way has to refuse to, so every frame violation
throws. fseek() past the end of a file succeeds, which is why the skip walk
compares the position with the file size after every entry - without that a
truncated section is handed out as a callback pointing past the end.

No cache version bump is needed: a cache in the old PHP format fails to
unserialize and is discarded like any other unreadable file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
unserialize() has no reconstruction hook, so a cache written by a PHPStan
whose classes have since changed can fail while the objects are rebuilt: a
property the payload does not carry stays uninitialized and reading it
throws. var_export absorbed this because __set_state() reconstructs through
the constructor, which applies the declared defaults - renaming Error::$tip
is harmless there and aborted the run here.

cacheVersion and phpstanVersion keep a released version away from another
release's objects, but phpstanVersion comes from Composer's installed.php
rather than the working tree, so a source checkout keeps one value across
every edit of these classes. That is where it is reachable, and it is also
where PHPStan is developed.

The four callbacks are now invoked inside restore()'s failure path, so a
cache that cannot be read back is discarded and everything re-analysed, the
same as for a damaged file. Before this the same shape aborted the command
with an uncaught error and a usage dump - on both formats, since the
var_export closures are evaluated at the same point.

e2e/result-cache-stale-objects renames Error::$message inside the cache file,
which is that shape without needing two PHPStan versions installed, keeping
the byte length so the frame headers stay valid. The run reports the cause
and re-analyses; without the change it dies on the uninitialized property.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ondrejmirtes
ondrejmirtes force-pushed the result-cache-serialize branch from b87b09c to 25e7493 Compare September 3, 2026 19:25
@ondrejmirtes
ondrejmirtes merged commit 02f74b1 into phpstan:2.2.x Sep 3, 2026
491 of 494 checks passed
@ondrejmirtes

Copy link
Copy Markdown
Member

Thank you!

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.

3 participants