pb30/phpstan-composer-analysis
A PHPStan extension that analyzes your Composer dependencies alongside your code, flagging unused required packages and missing package requirements. Keep composer.json in sync with real usage while running your existing static analysis.
Install alongside the analyser it wraps:
composer require --dev pb30/phpstan-composer-analysis shipmonk/composer-dependency-analyser
The wrapper does not replace shipmonk/composer-dependency-analyser — it turns its findings into PHPStan rule violations, so both packages need to be present.
Register the extension. Add the packaged extension.neon to your phpstan.neon (or to the config used by your CI job):
includes:
- vendor/pb30/phpstan-composer-analysis/extension.neon
Create the analyser config file that the wrapper reads. This is the normal shipmonk/composer-dependency-analyser config, a PHP file returning a Configuration object:
<?php
// composer-dependency-analyser.php
declare(strict_types=1);
use ShipMonk\ComposerDependencyAnalyser\Config\Configuration;
return (new Configuration())
->addPathToScan(__DIR__ . '/src', isDev: false)
->addPathToScan(__DIR__ . '/tests', isDev: true);
Point the wrapper at it if the default path isn't picked up (exact parameter name is defined in the package's extension.neon — open that file first, it is the authoritative reference):
parameters:
composerDependencyAnalysis:
configFile: %currentWorkingDirectory%/composer-dependency-analyser.php
First use case: run vendor/bin/phpstan analyse on a project that has been running the standalone analyser. Before wiring the wrapper, run vendor/bin/composer-dependency-analyser once to confirm the config is valid and to see the baseline set of findings. Then re-run PHPStan: the same unused/shadow dependency findings should appear as PHPStan errors, now in the same output and exit code as your static analysis. The quick win is deleting a CI step and a separate tool configuration.
Treat PHPStan as the single entrypoint. Once the extension is registered, drop the standalone composer-dependency-analyser invocation from CI and let phpstan analyse be the only command. This means one cache, one exit code, one place to suppress findings.
Split scan paths by dev/prod. The analyser's core value is knowing whether a package is used from src/ (so it must be in require) or only from tests/ (so it belongs in require-dev). Be explicit:
return (new Configuration())
->addPathToScan(__DIR__ . '/src', isDev: false)
->addPathToScan(__DIR__ . '/tests', isDev: true)
->addPathToScan(__DIR__ . '/config', isDev: false)
->addPathToScan(__DIR__ . '/migrations', isDev: false);
Missing a scan path produces a false "unused dependency" for packages only referenced there.
Run it as a separate CI job from the main PHPStan analysis. Dependency findings depend on composer.lock state, not on your source-level baseline. A dedicated job keeps the cache keys clean:
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/phpstan analyse -c phpstan-composer.neon --no-progress
Where phpstan-composer.neon is a minimal config that only includes this extension and sets paths to your scan roots — this avoids dragging in the full rule set just to check dependencies.
Use it as a guard against composer require drift. In code review, a PR that adds a package to composer.json but not to code (or adds a use statement without a composer require) becomes a visible PHPStan error instead of a review nit. Pair it with a composer validate --strict step.
Suppress narrowly, per package. Prefer the analyser's own ignore mechanism in composer-dependency-analyser.php over PHPStan's ignoreErrors, because the analyser understands package/error-type granularity:
use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType;
return (new Configuration())
->addPathToScan(__DIR__ . '/src', isDev: false)
->ignoreErrorsOfType(ErrorType::UNUSED_DEPENDENCY, ['symfony/polyfill-*']);
Reserve ignoreErrors in phpstan.neon for cases where the wrapper reports at a file/line you cannot otherwise target.
Local pre-push hook. Because the check is fast (it reads composer.lock and tokenizes files rather than doing full type inference), it is a good candidate for a pre-push hook or a composer check script, not just CI.
composer.lock must exist and vendor/ must be installed. The analyser inspects the installed package graph. Running PHPStan in a bare checkout, or after composer install --no-dev while scanning test paths, yields misleading results. Always composer install (with dev) before this job.
PHPStan's result cache will not invalidate on composer.json/composer.lock changes. PHPStan caches per analyzed file. If you add or remove a dependency without touching PHP sources, you can get stale results. Either run this check with --no-progress --debug and no cache, or clear the cache in CI. A quick fix in scripts: vendor/bin/phpstan clear-result-cache before the dependency job.
Reported locations may be outside your paths. Dependency errors are naturally associated with composer.json, not a PHP file. Depending on how the wrapper registers rules, PHPStan may report at a synthetic path or at the config file. If a rule appears to never fire, check whether PHPStan is filtering it out because the file is not under paths — narrow the paths/analyse config for this job to the scan roots and see whether findings appear.
Polyfills, ext-*, and meta-packages generate noise. symfony/polyfill-*, phpstan/extension-installer, and packages pulled in only for their side effects (Composer plugins, framework recipes) will often look unused or shadowed. Add them to ignoreErrorsOfType(ErrorType::SHADOW_DEPENDENCY, [...]) / UNUSED_DEPENDENCY rather than disabling the check. Note that ext-* requirements should not be flagged at all — if they are, you are on an old analyser version.
Dynamic class references defeat usage detection. Classes referenced only via class-string config strings, container service ids, Doctrine annotations, or call_user_func on a concatenated name are invisible to the analyser. This is the dominant source of false "unused dependency" reports. Register these packages as ignored, or add an explicit use/class_exists() reference in a scanned file. The latter is honest but a bit ugly — prefer the ignore list and a comment.
Pin the wrapper version and read extension.neon on upgrade. This is a low-traffic package (17 stars), so the extension's parameter names and rule registration can change between releases without a deprecation path. After composer update, diff vendor/pb30/phpstan-composer-analysis/extension.neon and confirm the parameter names you set in phpstan.neon still exist. A silently-ignored parameter in NEON produces no error — the extension just falls back to defaults, and you may end up scanning the wrong directories.
PHPStan major version compatibility. Confirm the installed wrapper release supports your PHPStan major (1.x vs 2.x); a rule set compiled against one major often fails to load or fails to register on the other. Check composer why-not pb30/phpstan-composer-analysis if Composer resolved an unexpectedly old version.
Verify the wrapper is actually active. A good smoke test: temporarily add an unused package to composer.json, run composer update <pkg>, then run PHPStan. If no dependency error appears, the extension is not loaded (wrong include path, or the includes entry added to a config file not being used). Remove the package afterwards.
Extension point to know: the underlying Configuration object supports setComposerJsonPath()/setComposerLockPath() and unknown-class analysis toggles if you analyse a monorepo whose composer.json is not at the project root. Set these explicitly rather than relying on the wrapper's default working-directory assumptions — those assumptions are the usual cause of "it works locally, finds nothing in CI".
How can I help you explore Laravel packages today?