Skip to content

PHPStan: upgrade phar 2.2.3 -> 2.2.7, and evaluate PHPStan Turbo (research complete) #18

Description

@ec-vasyl

Summary

PHPStan 2.2.6 introduced PHPStan Turbo — an optional native C++ extension (PHP 8.3+) that makes analysis 10–30 % faster with bit-for-bit identical output. We are pinned at 2.2.3 in phive.xml.

Recommendation: bump the phar to 2.2.7. Do NOT ship the turbo binaries yet.

Research was done read-only (no repo changes) and the key claims were verified by execution in a php:8.4-cli container. Full writeup lives outside the repo at untracked/PHPSTAN-TURBO-RESEARCH.md; this issue carries the substance.


Where we are

Pinned now phpstan ^2.2.3, installed="2.2.3"
Latest 2.2.7 (2026-07-29)
Delivery model PHIVE phar in vendor-phar/, plus "replace": {"phpstan/phpstan": "*"} — the Composer package is never installed

Relevant releases: 2.2.4 · 2.2.5 · 2.2.6 · 2.2.7


Finding 1 — the "you get it automatically" headline does not apply to us

The announcement says Composer users get Turbo automatically and phar users should pie install phpstan/turbo. Both halves are misleading for our architecture.

Reading TurboExtensionSelector, the binaries are located next to the phar:

$pharPath = Phar::running(false);
$file = sprintf('%s/turbo-ext/%s/phpstan_turbo-%d.%d%s.%s', dirname($pharPath), $platform, ...);

Composer users only get it automatically because the package is the git repo where phpstan.phar and turbo-ext/ are siblings. So we do not need PIE — we could ship the binaries in vendor-phar/ and every consumer would get it with zero action.

Verified. With the binary staged next to the phar, phpstan diagnose reports:

Turbo extension: enabled in worker processes (the main process runs without it)
Turbo platform: linux-gnu-x86_64 (os: Linux, machine: x86_64, libc: gnu, php: 8.4, zts: no, debug: no)
Turbo worker binary: /w/turbo-ext/linux-gnu-x86_64/phpstan_turbo-8.4.so

Control (directory moved away): Turbo extension: not loaded.

Note PHPStan states the limitation itself — worker processes only. The main process stays un-accelerated, so a path-scoped run that never forks workers gains nothing.

Finding 2 — output identity holds ✅

Same config, result cache cleared, --error-format=raw, with vs without turbo: 148 errors both ways, diff empty. Upstream's bit-for-bit promise is sound.

Finding 3 — turbo costs ~36 % more memory ⚠️ (not in any changelog)

This surfaced only by running it:

Used memory (PHPStan -v, total across workers)
turbo ON 643.06 MB
turbo OFF 474.05 MB
delta +169 MB (+35.7 %)

Crash-threshold sweep (per-process limit, 51-file analysis):

Limit turbo ON turbo OFF
128M CRASHES (3/3 reproductions) ok
160M+ ok ok

Consequences:

  1. Turbo largely gives back 2.2.4's memory win (that release cut usage 2.8 GB → 2.1 GB; turbo adds ~36 % back). A fair trade — speed for memory — but a trade, and the two facts live in different places upstream.
  2. Harmless at our 4 G default (phpqaMemoryLimit, applied via phpNoXdebug in includes/functions.inc.bash and propagated to workers). But any consumer on a lowered limit (our own docs give 2G as an example) or a memory-capped CI container gets a crash, not a slowdown.

Finding 4 — the version-pin drift is real and fast-moving

The extension version is the short SHA of the last commit touching turbo-ext/src/, and the phar hard-pins the one it expects. A mismatch silently deactivates the extension (prints a note, runs without it).

Expected extension version
phpstan 2.2.6 2d4b432
phpstan 2.2.7 86312b7
turbo-ext default branch (today) 1ecf6e0

It changed between two consecutive patch releases three days apart. This means:

  • The PIE route is unmanageable for an estate — every developer and CI image would have to re-run pie install in lockstep with every phar bump, or silently lose the extension.
  • If we ever vendor the binaries, they must be re-copied from the same tag as the phar on every bump, with a parity check asserting turbo-ext/.version matches — otherwise we carry ~5 MB of dead weight and never notice.

Blast radius — this repo

Low-moderate, and of the ordinary kind. Mechanically a phive bump plus a rebuilt phar blob (~27 MB, business as usual for this repo).

  • All three stricter new checks in 2.2.6 are Bleeding Edge only (unnecessary ?? null, impossible in_array() needles, non-empty-string DOMDocument args). We include bleedingEdge.neon nowhereconfigDefaults/generic/phpstan.neon is just level: max. So they cannot fire for us.
  • Changes that can add errors at level: max: stronger explode() narrowing (2.2.5/2.2.6), list-shapes for functionMap returns, invalid preg_replace_callback() flags, curl_setopt callback signatures. Expect tautologies (alreadyNarrowedType / alwaysTrue / alwaysFalse) especially in tests — exactly what the educational note already in includes/generic/phpstan.inc.bash was written for.
  • Custom rules: near-zero API risk. All 28 rules in src/PHPStan/Rules/ use only Rules\Rule, RuleErrorBuilder, Analyser\Scope, plus a little ReflectionProvider / InClassNode / FileNode / Type. Long-stable public API.
  • 2.2.7 is bugfixes + perf + internals only — no new rules, no new Bleeding Edge checks. It adds upgrade value at near-zero extra risk over 2.2.6, and notably contains a turbo bugfix ("Do not key the container cache on the turbo extension state").
  • Upside: 2.2.4 cut memory 2.8 GB → 2.1 GB. Good reason to take this upgrade regardless of turbo.
  • PhpStanGuardPlugin validates the phar version against the extension-installer constraint; moving forward can only make that check more likely to pass.
  • Result cache is invalidated by any version change → one full re-analysis everywhere, once.

Blast radius — downstream consumers

This is where the risk concentrates, because consumers inherit the version without choosing it.

  • Their next composer update lts/php-qa-ci silently moves them 2.2.3 → 2.2.7, at level: max, on codebases we have never seen.
  • Under GitHub Actions, GITHUB_ACTIONS=trueqaReadOnly ⇒ aggregate mode, so new errors are a hard CI failure, not a nag — a consumer's green pipeline can go red from our dependency bump with no change of theirs.
  • One-off full re-analysis on their first post-upgrade run.
  • Ship this as a clearly flagged release, noting that new PHPStan true-positives are expected.

If we later ship turbo binaries, the per-install cost is:

Set Raw ~Packed (measured 35.3 % gzip)
Everything upstream ships 20.8 MB ~7.3 MB
PHP 8.4 only, non-ZTS, all 6 platforms 4.98 MB ~1.7 MB
linux-gnu-x86_64, PHP 8.4 only 851 KB ~300 KB

linux-musl-* (Alpine CI) and macos (dev laptops) are separate binaries; omitting them means those hosts silently get nothing.


Recommendation

  1. Bump the phar 2.2.3 → 2.2.7 as its own commit, so any new PHPStan errors are unambiguously attributable to the version change. Update phive.xml and re-run scripts/tool-install.bash update.
  2. Run vendor/bin/qa -t stan afterwards — 2.2.6/2.2.7 heavily refactored extension registration (ExtensionsCollection, #[AutowiredExtensions], nette/di 3.1.10). Confirm the 28 custom rules still register. This is the one check I would not skip.
  3. Defer the turbo binaries. We have no speed measurement on our own code, they cost +36 % memory, and upstream is visibly still stabilising them (a turbo bug fixed within 3 days).
  4. If/when we revisit: adopt the sibling-directory route (never PIE), fetch turbo-ext/ from the same tag as the phar inside tool-install.bash, and add a version-parity assertion.

Still unmeasured

  • Speed on our own codebase. The 10–30 % claim is unverified here; the container test was too small (51 files, startup-dominated) to measure. Needs an interleaved A/B on user CPU.
  • phpNoXdebug compatibility (php -n -c <generated-ini>) — code-reading says fine, not yet observed.
  • New error count at level: max with our full rule set — the container run used a bare level-5 config.

Related

  • #3 (closed) — PHPStan: foolproof phar/extension management with composer update. Same problem family; worth reading before implementing.
  • #10 — PHPStan path-coverage check.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions