Skip to content

bootstrap function_exists checks wrong FQCN; PHPStan redeclares functions #109

Closed

Description

@LucasHantz

Bug Report

Q A
BC Break no
Version 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

  1. 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.
  2. 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.
  3. 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.

How to reproduce

composer require veewee/xml:3.5.0 phpstan/phpstan
// src/Example.php
<?php
use function VeeWee\Xml\Dom\Builder\value;

value('x');
# phpstan.neon
parameters:
    level: 0
    paths:
        - src
vendor/bin/phpstan analyse

(Also fails under PHPStan parallel workers with … while running parallel worker.)

Confirm the wrong FQCN:

php -r 'require "vendor/autoload.php";
var_export(function_exists("VeeWee\\Xml\\Dom\\Builder\\value")); echo "\n";
var_export(function_exists("Xml\\Dom\\Builder\\value")); echo "\n";'
# true
# false

Expected behaviour

  • 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.

Activity

  1. veewee commented on Jul 23, 2026

    @veewee
    Owner

    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.

  2. LucasHantz commented on Jul 23, 2026

    @LucasHantz
    Author

    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):

    if (!\function_exists(__NAMESPACE__ . '\value')) {
        function value(string $value): Closure
        {
            // …
        }
    }

    Minimal repro (still fails after 3.8.0 FQCN fix)

    composer require veewee/xml:3.8.0 phpstan/phpstan
    // src/Example.php
    <?php
    use function VeeWee\Xml\Dom\Builder\value;
    value('x');
    # phpstan.neon
    parameters:
        level: 0
        paths:
            - src
    vendor/bin/phpstan analyse

    Confirm functions are already loaded from Composer before PHPStan re-includes the file:

    php -r 'require "vendor/autoload.php";
    var_export(function_exists("VeeWee\\Xml\\Dom\\Builder\\value")); echo "\n";
    require "vendor/veewee/xml/src/Xml/Dom/Builder/value.php";'
    # true
    # then: Cannot redeclare function …

    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.

  3. veewee commented on Jul 23, 2026

    @veewee
    Owner

    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:

    1. 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.
    2. 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.
    3. 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.
    4. 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.

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions