Empirical finding
PR #909 added the CI job from #895. First run revealed the plugin does not currently work under psalm.phar when installed via composer. Every matrix cell failed identically:
PHP Fatal error: Cannot redeclare class Psalm\Internal\ErrorHandler
(previously declared in phar:///.../psalm.phar/src/Psalm/Internal/ErrorHandler.php:20)
in /.../vendor/vimeo/psalm/src/Psalm/Internal/ErrorHandler.php on line 24
Full failure logs: https://github.com/psalm/psalm-plugin-laravel/actions/runs/25658024029
Root cause
psalm/plugin-laravel's composer.json declares vimeo/psalm in require (not require-dev). So composer require --dev psalm/plugin-laravel always pulls vendor/vimeo/psalm alongside the plugin. When the user then runs psalm.phar (instead of vendor/bin/psalm):
psalm.phar boots and loads its own bundled Psalm\* classes from the phar's internal phar:// paths.
- Something during plugin init (probably the Testbench Laravel boot in
ApplicationProvider) triggers loading of the project's vendor/autoload.php.
- Composer's autoloader is now registered with
Psalm\* mapped to vendor/vimeo/psalm/src/.
- When PHP encounters a
Psalm\Internal\X class lookup (or a hardcoded require_once of a Psalm internal from a sibling file), it loads the file from vendor/vimeo/psalm/, re-declaring a class already loaded from the phar → fatal.
The trace shows the redeclaration is require_once-driven (from LanguageServer.php:50), not autoload-driven. Psalm internals use explicit require_once chains in places, so even if PHP's class table has the symbol, a sibling file getting loaded forces the redeclaration.
This is category-wide
psalm-plugin-symfony has the identical composer.json shape (vimeo/psalm in require) and only tests via vendor/bin/psalm in its CI (.github/workflows/integrate.yaml). No phar-related issues filed there either. Every Psalm plugin that requires vimeo/psalm via composer is presumably affected.
Why this blocks #895's cleanup
#895 wanted CI evidence that Plugin::registerHandlers()'s require_once defense is unnecessary, so we can collapse the 41-handler block into a foreach loop. The defense was designed to handle "autoloader not registered for Psalm\LaravelPlugin\* when the plugin entry point runs". This issue is a different failure mode entirely: the plugin can't even boot under psalm.phar because of class collision, regardless of how handlers are registered.
So: this issue must be fixed first. Then #895's CI job (already merged via #909) can produce a meaningful green-or-red signal on the require_once defense.
Possible directions
-
Move vimeo/psalm to require-dev. Phar users wouldn't get a composer-installed Psalm; the phar's bundled one would satisfy use Psalm\* references. Tradeoff: users who only use vendor/bin/psalm would need to keep vimeo/psalm explicitly in their own require-dev for the binstub to exist. Possibly a breaking change.
-
Use Composer replace / provide. Declare that the plugin "replaces" or "provides" vimeo/psalm, so composer doesn't co-install it. Unclear whether this works with the realities of Psalm versioning.
-
Detect phar context at runtime and skip plugin's Laravel boot. Check Phar::running() !== '' and short-circuit to a no-op (the test app needs the boot, but phar users running their own project don't necessarily). Probably introduces its own problems.
-
Coordinate a Psalm-side fix. Have psalm.phar detect a project-installed vimeo/psalm and refuse to load it / take precedence cleanly. Out of plugin scope.
Acceptance
A run of .github/workflows/test-laravel-app-phar.yml (added by #909) passes on at least the 12.12.2 × PHP 8.4 cell.
Empirical finding
PR #909 added the CI job from #895. First run revealed the plugin does not currently work under
psalm.pharwhen installed via composer. Every matrix cell failed identically:Full failure logs: https://github.com/psalm/psalm-plugin-laravel/actions/runs/25658024029
Root cause
psalm/plugin-laravel'scomposer.jsondeclaresvimeo/psalminrequire(notrequire-dev). Socomposer require --dev psalm/plugin-laravelalways pullsvendor/vimeo/psalmalongside the plugin. When the user then runspsalm.phar(instead ofvendor/bin/psalm):psalm.pharboots and loads its own bundledPsalm\*classes from the phar's internalphar://paths.ApplicationProvider) triggers loading of the project'svendor/autoload.php.Psalm\*mapped tovendor/vimeo/psalm/src/.Psalm\Internal\Xclass lookup (or a hardcodedrequire_onceof a Psalm internal from a sibling file), it loads the file fromvendor/vimeo/psalm/, re-declaring a class already loaded from the phar → fatal.The trace shows the redeclaration is
require_once-driven (fromLanguageServer.php:50), not autoload-driven. Psalm internals use explicitrequire_oncechains in places, so even if PHP's class table has the symbol, a sibling file getting loaded forces the redeclaration.This is category-wide
psalm-plugin-symfonyhas the identical composer.json shape (vimeo/psalminrequire) and only tests viavendor/bin/psalmin its CI (.github/workflows/integrate.yaml). No phar-related issues filed there either. Every Psalm plugin that requiresvimeo/psalmvia composer is presumably affected.Why this blocks #895's cleanup
#895 wanted CI evidence that
Plugin::registerHandlers()'srequire_oncedefense is unnecessary, so we can collapse the 41-handler block into aforeachloop. The defense was designed to handle "autoloader not registered forPsalm\LaravelPlugin\*when the plugin entry point runs". This issue is a different failure mode entirely: the plugin can't even boot underpsalm.pharbecause of class collision, regardless of how handlers are registered.So: this issue must be fixed first. Then #895's CI job (already merged via #909) can produce a meaningful green-or-red signal on the
require_oncedefense.Possible directions
Move
vimeo/psalmtorequire-dev. Phar users wouldn't get a composer-installed Psalm; the phar's bundled one would satisfyuse Psalm\*references. Tradeoff: users who only usevendor/bin/psalmwould need to keepvimeo/psalmexplicitly in their ownrequire-devfor the binstub to exist. Possibly a breaking change.Use Composer
replace/provide. Declare that the plugin "replaces" or "provides"vimeo/psalm, so composer doesn't co-install it. Unclear whether this works with the realities of Psalm versioning.Detect phar context at runtime and skip plugin's Laravel boot. Check
Phar::running() !== ''and short-circuit to a no-op (the test app needs the boot, but phar users running their own project don't necessarily). Probably introduces its own problems.Coordinate a Psalm-side fix. Have
psalm.phardetect a project-installedvimeo/psalmand refuse to load it / take precedence cleanly. Out of plugin scope.Acceptance
A run of
.github/workflows/test-laravel-app-phar.yml(added by #909) passes on at least the12.12.2 × PHP 8.4cell.