Skip to content

Plugin fails under psalm.phar due to dual-Psalm class collision #910

@alies-dev

Description

@alies-dev

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

  1. psalm.phar boots and loads its own bundled Psalm\* classes from the phar's internal phar:// paths.
  2. Something during plugin init (probably the Testbench Laravel boot in ApplicationProvider) triggers loading of the project's vendor/autoload.php.
  3. Composer's autoloader is now registered with Psalm\* mapped to vendor/vimeo/psalm/src/.
  4. 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

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

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

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

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

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