| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Native PHP extension that reimplements PHPStan's hottest code paths in C++. It is entirely optional: PHPStan behaves identically without it, just slower. With the extension loaded, analysis output is bit-for-bit identical — only faster (~25% on PHPStan's own single-threaded self-analysis).
Most users do not need to install anything. The phpstan/phpstan Composer package ships prebuilt binaries for the most common platforms — Linux (glibc and musl, x86_64 and arm64), macOS (arm64), and Windows (x86_64), for PHP 8.3 and newer — and PHPStan automatically loads the one matching your runtime into its worker processes.
Installing the extension with PIE is only needed when you download and run phpstan.phar manually, outside of Composer (the prebuilt binaries ship next to the phar in the Composer package, not inside it):
pie install phpstan/turboUseful to know:
A plain Zend C++ extension, no framework dependencies.
Every shadowed piece of PHP code follows the same three steps:
Nothing is registered under PHPStan's names at module startup: reg::Class::shadow() only records a plan. Activation happens in PHP — TurboExtensionEnabler::activateIfCompatible(), called right after the Composer autoloader registers — which checks the extension's version and calls Runtime::activateShadowing() (Shadow.cpp). That declares each plan as a linked user class carrying the twin's real name: built the way the compiler builds one (zend_initialize_class_data, internal method entries, zend_do_link_class()), so the parent and the interfaces are resolved through the autoloader and the class passes the same inheritance and signature checks a PHP declaration gets. PHPStan\TrinaryLogic is the native class; the PHP twin is never loaded, and every reference, instanceof, type hint and DI lookup resolves to it. PHP classes may extend a native class and a native class may extend a PHP one or another native one. The twin's source file is recorded as the class's file, so reflection — and PHPStan's own AutoloadSourceLocator — keeps reading the PHP declaration with its PHPDocs and attributes.
Internal classes could not do this: they are registered at module startup, before any userland parent or interface exists, and PHPStan's classes implement userland interfaces (PHPStan\Type\Type) and extend each other.
The consequences for a port:
Class names the native code references at run time come through PHPStanTurbo\Runtime::configure(): TurboExtensionEnabler feeds it the generated vendor/turbo-class-map.php, derived from the #[ReferencedByTurboExtension] attributes (vendored PhpParser classes are hardcoded in the collector), so a renamed class updates the map on the next autoloader dump. Shadowed classes are never referenced that way — the native code holds their class entries itself and instantiates them directly.
Two more Runtime entry points serve fork mode (see ForkParallelChecker): enablePharForkGuard() gives each pcntl_fork()ed worker a private cursor on the running phar's fd (PharForkGuard.cpp), and exitImmediately() — _exit() with the engine's exit status — is how ForkedChildTerminator ends a forked worker. A forked child inherits every loaded extension but none of their threads, so PHP's full teardown can wedge in a fork-unsafe extension's module shutdown (ext-grpc without grpc.enable_fork_support); a forked child that does not exec() must _exit() instead.
Runtime::trustTypesUnder() is the one entry point that changes how PHPStan's own code runs rather than replacing it: it arms an opcache optimizer pass (TrustedTypes.cpp) that drops the engine's argument and return type checks from scripts compiled under the given prefix — the running phar, passed by TurboExtensionEnabler::trustOwnTypesIfSuitable() — the running phar, or the source checkout bin/phpstan runs from. PHPStan's code is verified by PHPStan, so those checks re-check what analysis proved, at about 8% of the analysis CPU. Nothing outside the prefix is touched, and a check sits in the callee, so extensions and bootstrapped code keep checking what PHPStan hands them; what goes is the TypeError at the boundary when they pass PHPStan a wrong value — a --debug run keeps the checks for exactly that. Float-coercing signatures, typed variadics and typed property writes stay checked (TrustedTypes.cpp explains each); tests/trusted-types.php pins the behaviour.
The extension is version-pinned (TurboExtensionEnabler::EXPECTED_EXTENSION_VERSION); a mismatched extension never gets activateShadowing() called and the PHP implementations load as usual (Runtime's fork guard and trusted-types pass do not depend on it). There is no runtime kill switch — the only way to run without it is not to load it.
The version is the short SHA of the last commit that touched turbo-ext/src/. The binary's (actual) version is baked in at build time — the Makefile computes it from git over the same path — so only the expected side, TurboExtensionEnabler::EXPECTED_EXTENSION_VERSION, is maintained (by make bump-turbo, which edits and commits it). After changing the native side, verify the implementations still match and run make bump-turbo (recomputed after the change lands on the target branch — a pull request commit gets a new SHA when rebased, and rerunning refreshes an unpushed bump commit in place); the phar.yml turbo-version job enforces the SHA and the compile job verifies the built binary reports what the enabler expects. A PHP-twin-only edit does not move the version — keeping the pair in sync there is on the review and the differential tests, not the version gate. Builds outside the monorepo — the phpstan/turbo-ext subsplit and PIE source builds from it — cannot ask git (the subsplit's replayed commits have different SHAs, tarballs have no checkout at all), so the subsplit workflow generates and commits a VERSION.txt there and the builds fall back to it; the file must never exist in the monorepo. With neither git nor VERSION.txt the version bakes as "dev", which the enabler rejects — the extension then simply stays inactive.
The manifest of shadowed pairs — each PHP class and the C++ file implementing it natively — is derived from the #[ShadowedByTurboExtension] attributes: the attributed file is the PHP side, the attribute names the native class and the implementing .cpp. Nothing is maintained by hand; build/generate-turbo-manifest.php derives the same map into vendor/turbo-shadowed-classes.json on every composer dump-autoload (for the runtime consumers: the enabler's activation, the phar's preload builder, tests/signature-parity.php), and the vendored PhpParser\NodeTraverser pair, which cannot carry the attribute, is hardcoded in build/TurboAttributeCollector.php — the collection and rendering shared by that script and bin/side-by-side.php. The manifest drives three things:
Semantic equivalence is still proven by the differential smoke test and by running the full test suite with the extension loaded — the manifest checks guard structure and force the version bump ritual, not behavior. The smoke test also guards two structural invariants at the real runtime: the generated class map must cover the native class-reference table exactly (Runtime::classRefs()), and every shadowed class must have registered differential coverage (in $covered there, or via its dedicated script).
src/parser/ reimplements php-parser 5.9.0's LALR engine and node building (PhpParser\ParserAbstract + the generated Parser\Php8), shadowed through the PHPStan\Parser\ParserRunner seam. The parsing tables are read at run time from the first Php8 parser object seen — they are generated data, so nothing is duplicated — and node classes resolve relative to the parser's namespace, so no node class name is baked into the binary. Tokenization stays in PHP's C tokenizer (one Lexer::tokenize() crossing per file); everything after — the shift/reduce loop, all 482 semantic actions, attribute arrays, node construction (direct property-slot writes derived from constructor parameter names; classes with non-trivial constructors call the real PHP constructor), error recovery, and comment annotation — is native. Non-Php8 parsers and non-string inputs fall back to $parser->parse().
Because the input domain is "all PHP source code", method-level parity is not enough here: tests/parser-corpus.php parses thousands of files with both implementations and requires byte-identical serialized ASTs, identical collected errors, and identical token streams. tests/parser-upstream-corpus.php runs the same comparison over every input of php-parser's own test suite at the installed commit (fetched from GitHub, or pass a checkout) — upstream's cases cover every grammar rule, error recovery and version-gated behavior, including syntax PHPStan's own code does not use yet. Both run in CI on every build.
The CI version job pins the php-parser version the engine was ported against (SUPPORTED_PHP_PARSER_VERSION in .github/workflows/phar.yml), so a composer.lock bump fails CI until the engine is consciously re-verified:
The generator itself (bin/generate-parser-actions.php) resolves php-parser constants (Modifiers::*, Stmt\Use_::TYPE_*, ...) under the Composer autoloader at generation time, decides per node class between property-slot writes (PN_NEW) and calling the real PHP constructor (PN_NEW_CTOR) — verifying at generation time that slot-write classes have trivial assignment-only constructors — and fails the build on anything it cannot prove it handles. A brand-new node class with constructor logic shows up as such a failure and needs an entry in the generator's class-policy tables.
src/ArenaCache.cpp (shadowing PHPStan\Cache\ArenaCache, whose PHP twin is a cache that never hits) shares lazily-computed read-mostly data across the parallel worker processes of a single run. The master creates a named shared-memory object (POSIX shm_open / Windows pagefile-backed section) before spawning workers and passes the name via the worker command's --arena option; whichever process first computes a record publishes it, and the arena's physical pages are shared, so N workers stop paying N copies. Lifetime is exactly one run — no persistence, no invalidation: the master unlinks the name once every worker's TCP hello arrived (the mapping stays valid; the kernel reclaims the memory with the last process, even after SIGKILL) and destroys the mapping when the analysis ends. PHPSTAN_ARENA=0 disables just the arena.
Records are flat, offset-based, position-independent blobs of data-only PHP values — nothing in the mapping is ever seen by the GC, so shared pages are never dirtied by refcounting. Publication is lock-free (bump-allocate, write, CAS an index slot from 0 with release ordering); racing publishers of the same key converge on the first writer, wasteful-not-unsafe. Corruption degrades to a miss via bounds checks — the caller recomputes locally, like a worker that never attached.
Consumers: the function signature map (FunctionSignatureMapProvider), published once as a hash record and read per-row so workers stop materializing the multi-megabyte merged map; the per-directory symbol indexes (OptimizedDirectorySourceLocatorFactory), fingerprint-bound and read lazily per name; and — generically — every data-only entry of PHPStan\Cache\Cache, so each var_export'd cache blob is include()d by one process per run instead of every worker (object-carrying payloads stay per-worker: encoding them double-buffers exactly when worker memory peaks). tests/arena-smoke.php is the cross-process differential test.
cd turbo-ext
make # builds phpstan_turbo.soor, from the repository root, make build-turbo — the same build, run in parallel over the machine's cores and followed by a check that the binary's baked version is the one TurboExtensionEnabler activates (a build made between a turbo-ext/src commit and its make bump-turbo loads but stays inactive).
The only requirements are a C++17 compiler and php-config on PATH (or passed as make PHP_CONFIG=..., make build-turbo PHP_CONFIG=... from the root). Both NTS and ZTS interpreters are supported — the build inherits thread-safety from the php-config it is pointed at (ZTS hosts like PMMP's bundled PHP get a matching build; PHPStan itself only ever runs the native code single-threaded).
The standard phpize && ./configure && make pipeline works too (config.m4) — it is what PIE drives when it builds the phpstan/turbo package from source, e.g. for combinations without a prebuilt binary. That path bakes the version from VERSION.txt (present only in the phpstan/turbo-ext subsplit, where its workflow commits it — in the monorepo build with make instead), and its ./configure overwrites this directory's Makefile with the generated one (git restore Makefile brings it back; PIE builds in its own extracted copy). The hand-written Makefile stays the primary build: it statically links libstdc++/libgcc on Linux — the distributed binaries must not depend on the build host's GLIBCXX symbol versions — and carries the strict warning setup, neither of which survives the libtool link.
On Windows the extension builds through the standard PHP extension pipeline (config.w32): with a PHP devel pack, php-sdk-binary-tools and a VS2022 x64 developer prompt, run phpize && configure --enable-phpstan-turbo && nmake inside turbo-ext/. The toolset generation matters for distribution: PHP's module loader rejects DLLs linked with a newer MSVC generation than the PHP core, and the official php.net binaries are built with VS2022 (toolset 14.4x) — so CI builds on windows-2022, not windows-latest (whose VS2026 image links with 14.5x). Set the PHPSTANTURBO_VERSION environment variable before configure to bake the version (the Makefile computes it from git automatically; config.w32 reads it from the environment, falling back to VERSION.txt).
make applies protections by default, each probed against the compiler in use rather than assumed, because the targets disagree: GCC 11.4 (the CI floor) rejects -ftrivial-auto-var-init, -fcf-protection is x86-only, and -fstack-clash-protection is accepted but silently unused by Apple clang on arm64. What survives the probe on Linux is stack canaries, clash protection, _FORTIFY_SOURCE=3, register clearing on return, array-bounds checks that trap without linking a sanitizer runtime, and a GOT made read-only before control is handed over. make HARDENING_FLAGS= builds without them.
Every flag was measured before adoption, on interleaved A/B pairs judged on user CPU: the protections together came to −0.16% (t=−0.70), and the size flags -fvisibility=hidden and -fno-exceptions -fno-rtti to +0.20% and +0.05% — all inside the noise floor, while the shipped .so shrank ~26%.
Those timings are from macOS, where -fvisibility=hidden changes no machine code at all: Mach-O's two-level namespace already prevents interposition, so only the symbol table shrinks. On ELF it is far from cosmetic — measured in the CI image, it removes 8,866 of 9,907 dynamic symbols and 18.9% of __TEXT, because a default-visibility symbol can be interposed at load time and therefore neither inlined nor garbage-collected. The Linux binaries are where that matters, and where it has not been timed.
-fno-exceptions -fno-rtti binds new code: a throw or a dynamic_cast becomes a compile error. That matches the style rules above — the engine unwinds with longjmp, and the ports neither throw nor use RTTI — but it is a constraint, not merely an optimisation.
Measured and not adopted, so they are not re-tried: thin LTO (+1.76% slower, t=+3.44 — it inlines across translation units and loses more in locality than it gains), -O3 (a wash at +4.63% code), PGO (no longer shows the benefit it was originally landed on, even measured on its own training corpus), and -fstrict-flex-arrays=3, which traps on ordinary string and property access because the engine's public structures use the C struct-hack (zend_string.val[1], zend_object.properties_table[1] behind OBJ_PROP_NUM) — all 29 violation sites in this codebase were that macro expanding, none of them fixable here.
Add to php.ini (recommended — parallel worker processes inherit it):
extension=/absolute/path/to/phpstan-src/turbo-ext/phpstan_turbo.soThe native sources are C++ that mirrors the PHP implementations they replace: each shadowed class is a handle class in namespace phpstanturbo with the twin's methods (see src/TrinaryLogic.cpp for the reference shape), built on the zero-cost wrappers in src/zv.h — borrowed zv::Ref views, owned move-only zv::Val RAII values, range-for HashTable iteration, zv::Args argument packs for engine calls — and the shared bodies in src/TypeTraits.h (a member the ports would otherwise repeat verbatim forwards there). The wrappers compile to the same instructions as the raw zend macros (verified by interleaved A/B benchmark), so readability costs nothing. Classes register through the fluent builder in src/reg.h, which emits the raw zend structures with raw handler pointers — no per-call trampoline or argument boxing; each method's name, flags, signature and parameter-parsing glue live together in one declaration. A method that only parses its parameters and delegates them is declared by its handle member and parameter kinds (cls.method<&UnionType::accepts, zp::Obj, zp::Bool>(...)) and gets a generated handler; other glue parses with zp::parse<...>(). Both expand to the engine's own ZEND_PARSE_PARAMETERS macros, so the handlers compile to what the hand-written glue did. Raw zend form remains where an abstraction would not be provably free — always with a comment saying so.
make lint-turbo # clang-tidy over the hand-written sources
make sanitize-turbo # the differential tests under UndefinedBehaviorSanitizerlint-turbo runs clang-tidy with the curated check list in .clang-tidy; a finding fails the target, and there is no baseline. The list is curated because this is a Zend extension, and whole check families object to exactly that: the path-sensitive analyzer reads every zval access as an uninitialized union member (the type tag decides which member is live and it does not model that), others object to the engine's do {} while (0) macros, to php.h being an umbrella header, to the handler signatures' unused parameters, or propose linkage changes that measurably change inlining here. Every exclusion in .clang-tidy carries the number of findings it produced when the list was calibrated, so a future reader can re-judge it. Generated sources are not linted at all — a finding in src/generated/*.h or in the parser's action tables could only be fixed in their generator.
sanitize-turbo rebuilds the extension with UndefinedBehaviorSanitizer and runs the differential tests under it (smoke, arena-smoke, signature-parity, parser-corpus). The sanitizer runtime is linked into the .so, so an ordinary interpreter loads it — no debug or instrumented PHP build is needed. It builds from clean and cleans up after itself, because objects must never mix flags. This is the half that covers memory safety: the static checks cannot see zval lifetimes, and the differential tests already exercise the code paths that matter.
Both run in CI on every pull request (.github/workflows/lint.yml). The clang-tidy version is pinned in one place, CLANG_TIDY_VERSION in the Makefile: CI reads that number and installs exactly it (Ubuntu ships an older one, so it comes from apt.llvm.org), and make lint-turbo refuses to run with a different major. Without that, a green CI run and a green local run would be two different check lists rather than the same evidence, and the finding counts recorded in .clang-tidy would hold for neither. Install the pinned version with brew install llvm@<version> or from apt.llvm.org. Moving the pin is deliberate: raise the number, re-run, and fix or exclude what the new checks report — with its count and reason, the way the existing entries carry theirs.
Measured in the July 2026 benchmarks (callback-free absorptions gained 5–8.5% each, callback-dense ones ~1% or nothing):
Status quo, measured on PHPStan's own single-threaded self-analysis of src/ (interleaved A/B, user CPU): 59.6s with the extension vs 77.3s without — a 23% gain. The remaining cost is structural, not hotspot-shaped: SPX counts ~535M userland calls spread over 20K functions, the top 120 functions by exclusive time explain only ~15% of the run, and an on-CPU sample attributes 42.7% to VM call mechanics (frame setup, argument passing, return-type checks), 23.3% to other VM opcodes, ~8% each to memory/GC and syscalls — and only 2.3% to this extension's own code. Every further tier therefore means absorbing whole call subtrees, not porting leaf bodies.
What each gain level over the no-extension baseline requires:
(Benchmarks include the ShipMonk dead-code plugin, ~8% of the self-analysis run — third-party PHP that no port removes.)
# differential test of the native classes vs the PHP implementations (they
# are declared as PHPStanTurbo\* next to the twins, see tests/activate-prefixed.php)
php -d extension=$(pwd)/phpstan_turbo.so tests/smoke.php
# PHPStan's own test suite with the extension loaded
php vendor/bin/phpunit ...
# output identity (clear the result cache between runs!)
# nothing disables a loaded extension, so keep it out of php.ini and load it
# per run instead — through PHP_INI_SCAN_DIR, not -d extension=: PHPStan's
# OPcache restart re-executes the process and drops command-line -d flags
mkdir -p /tmp/turbo-ini && echo "extension=$(pwd)/phpstan_turbo.so" > /tmp/turbo-ini/turbo.ini
PHP_INI_SCAN_DIR=":/tmp/turbo-ini" php ../bin/phpstan analyse ... --error-format=raw # with
php ../bin/phpstan analyse ... --error-format=raw # without| Back | FazBrowse Home | New Git URL |