You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
3.5.0 (also present on 4.x bootstrap.php / build/bootstrap.php)
Summary
Composer files-autoload bootstrap guards loads with function_exists() using the wrong FQCN (Xml\… instead of VeeWee\Xml\…). Combined with unguarded function definitions, tools that later include the same function file (notably PHPStan) fatal with Cannot redeclare function.
Current behaviour
build/bootstrap.php generates map keys from the path under src/, e.g. Xml\Dom\Builder\value, while the real function is VeeWee\Xml\Dom\Builder\value.
Therefore function_exists($function) in src/bootstrap.php is always false for the names that matter; only require_once prevents a second load through this bootstrap.
PHPStan resolves use function VeeWee\Xml\Dom\Builder\value by including value.php again (plain include/require, not _once) after Composer already loaded it →:
Fatal error: Cannot redeclare function VeeWee\Xml\Dom\Builder\value()
(previously declared in …/vendor/veewee/xml/src/Xml/Dom/Builder/value.php:13)
in …/vendor/veewee/xml/src/Xml/Dom/Builder/value.php on line 13
This is the incomplete outcome of #6 / #57: #6 asked to wrap definitions; #57 only guarded the require_once.
Bootstrap keys use the real FQCN (VeeWee\Xml\…), so function_exists() matches defined functions.
Each function file wraps its definition in if (!function_exists(__NAMESPACE__ . '\…')) { function … }, so a later include of the same path is safe (Composer’s recommended pattern for files autoload).
PHPStan can analyse code that use function VeeWee\… without a fatal redeclare.
Nice catch, and I'm honestly a bit amazed this went unnoticed for so long. The generator was building the function_exists() guard keys without the VeeWee\ prefix, so every key was wrong and the guard could never match a defined function. It's been like that since the map was first generated.
Fixed in #110 (4.x) and #111 (3.x): the generator now uses the real FQCN and src/bootstrap.php is regenerated to match. I verified the fixed library analyzes cleanly under PHPStan.
One note on the expected-behaviour list: we're not going to wrap each function definition in if (!function_exists(...)) for now. Including the individual function files directly isn't how the library is meant to be used, so we'd rather keep the definitions plain and rely on the corrected bootstrap guard.
Thanks for shipping 3.8.0 / #111 — the bootstrap map keys are correct now (VeeWee\Xml\…), and a second trip through bootstrap.php is properly skipped via function_exists().
Unfortunately the PHPStan fatal is still reproducible on 3.8.0 (I also verified by applying only the FQCN key fix on top of 3.5.0). The remaining problem is that PHPStan does not go through bootstrap.php for the second load: after Composer’s files-autoload has already require_once’d the function file, PHPStan later does a plain include/require of the same path when resolving use function VeeWee\Xml\Dom\Builder\value. That bypasses the bootstrap guard entirely, and the bare function value(…) declaration fatals:
Fatal error: Cannot redeclare function VeeWee\Xml\Dom\Builder\value()
(previously declared in …/vendor/veewee/xml/src/Xml/Dom/Builder/value.php:13)
in …/vendor/veewee/xml/src/Xml/Dom/Builder/value.php on line 13
while running parallel worker
require_once in bootstrap cannot prevent a later include of the same file — only wrapping the definition does (what #6 originally asked for):
Thanks for pushing on this, you're right that 3.8.0 doesn't actually close it. Full disclosure: I leaned on Claude to introspect the runtime and trace exactly what triggers the redeclare, so the stack-trace archaeology below came out of that session rather than pure inspiration.
Why our check passed. The FQCN fix corrected the keys in src/bootstrap.php, so the function_exists() guard there finally matches the real function names. That makes a second pass through bootstrap.php a no-op, and that pass is the only thing our verification exercised: Composer's files-autoload walking the map, then walking it again. PHPStan never goes through bootstrap.php, so its route was never part of what we tested. The FQCN fix was needed, but it doesn't touch the path PHPStan takes.
Why it actually fatals. It's a collision between how the functions are shipped, how Composer autoloads, and how PHPStan locates symbols:
A PHPStan rule ends up asking "is there a class named VeeWee\Xml\Dom\Builder\value?" (in the case I reproduced, resolving a ::class through ReflectionProvider::hasClass()). That is a valid class name, so the question is legitimate. PHP keeps classes and functions in separate symbol tables.
To answer, BetterReflection's AutoloadFunctionsSourceLocator runs the registered autoloaders against the name. It checks class_exists/interface_exists/trait_exists first, but not function_exists, and unlike its sibling AutoloadSourceLocator it does not wrap the call in FileReadTrapStreamWrapper. So the include runs for real.
Composer's PSR-4 loader maps the name to a file by string alone and loads it with a plain include, not include_once. Because we ship one function per file with the filename equal to the function name on a PSR-4 path, ...\Builder\value resolves to .../Builder/value.php.
That file was already loaded via require_once by the files-autoload. A plain include re-runs it, and the bare function value() redeclares.
sequenceDiagram
autonumber
participant CA as Composer files-autoload
participant PHP as PHP function table
participant PS as PHPStan rule
participant AF as AutoloadFunctionsSourceLocator
participant CL as Composer ClassLoader
participant F as value.php
Note over CA,F: Phase 1: normal load (safe)
CA->>F: require_once value.php
F->>PHP: define function ...\value()
Note right of PHP: exists now ✓ (function table)
Note over PS,F: Phase 2: PHPStan analysis
PS->>AF: hasClass("...\Builder\value") ?
Note right of PS: valid CLASS name,<br/>classes and functions are<br/>separate symbol tables
AF->>AF: class/interface/trait_exists?<br/>no function_exists check
AF->>CL: run autoloader (UNTRAPPED)
Note right of CL: PSR-4: name to value.php<br/>by string only
CL->>F: include value.php (plain, not _once)
F-->>PHP: define function ...\value() AGAIN
Note over PHP,F: Cannot redeclare function
Loading
flowchart TD
A["Library<br/>one function per file,<br/>filename = function name,<br/>on a PSR-4 path"] --> D
B["Composer PSR-4<br/>maps FQCN to file by string;<br/>loads via plain include,<br/>not include_once"] --> D
C["PHPStan locator<br/>runs autoloaders untrapped<br/>to test hasClass();<br/>checks class/trait, not function"] --> D
D{{"A function's FQCN is a loadable class<br/>whose file re-executes on 2nd include"}}
D --> E["Cannot redeclare function"]
style E fill:#fbeae8,stroke:#b3271e,color:#b3271e
style D fill:#fbf1d9,stroke:#9a6b12
style A fill:#eaf0fb,stroke:#2f5fb0
style B fill:#fbf1d9,stroke:#9a6b12
style C fill:#fbeae8,stroke:#b3271e
Loading
Where I think the fix belongs. This is really a PHPStan issue. AutoloadFunctionsSourceLocator runs real autoloaders to test whether a name is a class, which assumes every autoloadable file is safe to include for its side effects. That holds for class files, not for files that define functions. Any package that ships function-per-file on a PSR-4 path hits the same thing: PHPStan will invalidly autoload and execute those functions, not just here. A function_exists() guard on our side would hide the symptom for this library, but the underlying behaviour (running autoloaders untrapped, and treating a function's FQCN as a class candidate) is general and lives in phpstan-src / better-reflection.
And really, none of this would exist if PHP just had a function autoloader. Classes get spl_autoload_register; functions get nothing, so every function library has to lean on Composer's files-autoload and quietly pray no tool ever includes the file a second time. Old man yelling at the cloud, but a spl_autoload_register for functions would have made this whole class of bug impossible.
Activity
veewee commentedon Jul 23, 2026
Nice catch, and I'm honestly a bit amazed this went unnoticed for so long. The generator was building the function_exists() guard keys without the VeeWee\ prefix, so every key was wrong and the guard could never match a defined function. It's been like that since the map was first generated.
Fixed in #110 (4.x) and #111 (3.x): the generator now uses the real FQCN and src/bootstrap.php is regenerated to match. I verified the fixed library analyzes cleanly under PHPStan.
One note on the expected-behaviour list: we're not going to wrap each function definition in if (!function_exists(...)) for now. Including the individual function files directly isn't how the library is meant to be used, so we'd rather keep the definitions plain and rely on the corrected bootstrap guard.
LucasHantz commentedon Jul 23, 2026
Thanks for shipping 3.8.0 / #111 — the bootstrap map keys are correct now (
VeeWee\Xml\…), and a second trip throughbootstrap.phpis properly skipped viafunction_exists().Unfortunately the PHPStan fatal is still reproducible on 3.8.0 (I also verified by applying only the FQCN key fix on top of 3.5.0). The remaining problem is that PHPStan does not go through
bootstrap.phpfor the second load: after Composer’s files-autoload has alreadyrequire_once’d the function file, PHPStan later does a plaininclude/requireof the same path when resolvinguse function VeeWee\Xml\Dom\Builder\value. That bypasses the bootstrap guard entirely, and the barefunction value(…)declaration fatals:require_oncein bootstrap cannot prevent a laterincludeof the same file — only wrapping the definition does (what #6 originally asked for):Minimal repro (still fails after 3.8.0 FQCN fix)
Confirm functions are already loaded from Composer before PHPStan re-includes the file:
Happy to test a PR that wraps the generated function files (or changes the generator in
build/bootstrap.php/ the function stubs) if that helps.veewee commentedon Jul 23, 2026
Thanks for pushing on this, you're right that 3.8.0 doesn't actually close it. Full disclosure: I leaned on Claude to introspect the runtime and trace exactly what triggers the redeclare, so the stack-trace archaeology below came out of that session rather than pure inspiration.
Why our check passed. The FQCN fix corrected the keys in
src/bootstrap.php, so thefunction_exists()guard there finally matches the real function names. That makes a second pass throughbootstrap.phpa no-op, and that pass is the only thing our verification exercised: Composer's files-autoload walking the map, then walking it again. PHPStan never goes throughbootstrap.php, so its route was never part of what we tested. The FQCN fix was needed, but it doesn't touch the path PHPStan takes.Why it actually fatals. It's a collision between how the functions are shipped, how Composer autoloads, and how PHPStan locates symbols:
VeeWee\Xml\Dom\Builder\value?" (in the case I reproduced, resolving a::classthroughReflectionProvider::hasClass()). That is a valid class name, so the question is legitimate. PHP keeps classes and functions in separate symbol tables.AutoloadFunctionsSourceLocatorruns the registered autoloaders against the name. It checksclass_exists/interface_exists/trait_existsfirst, but notfunction_exists, and unlike its siblingAutoloadSourceLocatorit does not wrap the call inFileReadTrapStreamWrapper. So the include runs for real.include, notinclude_once. Because we ship one function per file with the filename equal to the function name on a PSR-4 path,...\Builder\valueresolves to.../Builder/value.php.require_onceby the files-autoload. A plainincludere-runs it, and the barefunction value()redeclares.sequenceDiagram autonumber participant CA as Composer files-autoload participant PHP as PHP function table participant PS as PHPStan rule participant AF as AutoloadFunctionsSourceLocator participant CL as Composer ClassLoader participant F as value.php Note over CA,F: Phase 1: normal load (safe) CA->>F: require_once value.php F->>PHP: define function ...\value() Note right of PHP: exists now ✓ (function table) Note over PS,F: Phase 2: PHPStan analysis PS->>AF: hasClass("...\Builder\value") ? Note right of PS: valid CLASS name,<br/>classes and functions are<br/>separate symbol tables AF->>AF: class/interface/trait_exists?<br/>no function_exists check AF->>CL: run autoloader (UNTRAPPED) Note right of CL: PSR-4: name to value.php<br/>by string only CL->>F: include value.php (plain, not _once) F-->>PHP: define function ...\value() AGAIN Note over PHP,F: Cannot redeclare functionflowchart TD A["Library<br/>one function per file,<br/>filename = function name,<br/>on a PSR-4 path"] --> D B["Composer PSR-4<br/>maps FQCN to file by string;<br/>loads via plain include,<br/>not include_once"] --> D C["PHPStan locator<br/>runs autoloaders untrapped<br/>to test hasClass();<br/>checks class/trait, not function"] --> D D{{"A function's FQCN is a loadable class<br/>whose file re-executes on 2nd include"}} D --> E["Cannot redeclare function"] style E fill:#fbeae8,stroke:#b3271e,color:#b3271e style D fill:#fbf1d9,stroke:#9a6b12 style A fill:#eaf0fb,stroke:#2f5fb0 style B fill:#fbf1d9,stroke:#9a6b12 style C fill:#fbeae8,stroke:#b3271eWhere I think the fix belongs. This is really a PHPStan issue.
AutoloadFunctionsSourceLocatorruns real autoloaders to test whether a name is a class, which assumes every autoloadable file is safe to include for its side effects. That holds for class files, not for files that define functions. Any package that ships function-per-file on a PSR-4 path hits the same thing: PHPStan will invalidly autoload and execute those functions, not just here. Afunction_exists()guard on our side would hide the symptom for this library, but the underlying behaviour (running autoloaders untrapped, and treating a function's FQCN as a class candidate) is general and lives inphpstan-src/ better-reflection.And really, none of this would exist if PHP just had a function autoloader. Classes get
spl_autoload_register; functions get nothing, so every function library has to lean on Composer's files-autoload and quietly pray no tool ever includes the file a second time. Old man yelling at the cloud, but aspl_autoload_registerfor functions would have made this whole class of bug impossible.