Skip to content

Offer PHPStan's --fix as auto-fixer in the phpstan task - #1238

Open
Kanti wants to merge 1 commit into
phpro:v2.xfrom
Kanti:ccr-377c09f1-bxqh6w
Open

Kanti wants to merge 1 commit into
phpro:v2.xfrom
Kanti:ccr-377c09f1-bxqh6w

Conversation

@Kanti

@Kanti Kanti commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor
Q A
Branch v2.x
Bug fix? no
New feature? yes
BC breaks? no
Deprecations? no
Documented? yes
Fixed tickets -

PHPStan 2.3.5 made many built-in errors auto-fixable (e.g. method.missingOverride, cast.useless, phpunit.*). It marks them with 🔧 and ends its report with "Run PHPStan again with --fix".

This PR plugs that into GrumPHP's fixer the same way as the other fixable tasks (ECS, Rector, Mago): when the analysis fails and PHPStan reports fixable errors, the phpstan task returns a FixableTaskResult that reruns the same phpstan analyse command with --fix (same files, configuration, level and memory limit).

Why only when PHPStan reports fixable errors?

  • Most PHPStan errors (e.g. type errors) cannot be fixed automatically. In that case phpstan analyse --fix exits with 1 and "No fixable errors found", so GrumPHP would ask to fix and then report a failing fixer.
  • Older PHPStan versions never print the hint, so they keep the current behaviour. (In PHPStan ≤ 2.1, --fix launched PHPStan Pro instead of fixing.)

The hint for fixable ignored errors (--fix-ignored) is deliberately not taken into account, since ignored errors don't make the task fail. If PHPStan ever rewords the hint, the task falls back to the current non-fixable behaviour.

Tested manually against the PHPStan phars

  • 2.3.5 with a fixable and a non-fixable error: --fix exits with 0 even though the non-fixable error remains, the fixer reports ✔ and only #[\Override] was added.
  • 2.3.5 with only non-fixable errors: no fixer offered.
  • 2.3.4: no fixer offered, behaviour unchanged.
  • PHPStan passes the given paths on to its fix rounds, so only the files analysed by GrumPHP are changed.

The docs mention that --fix is experimental in PHPStan and also removes unmatched ignoreErrors entries from the configuration/baseline and unmatched @phpstan-ignore comments.

New Task Checklist:

  • Are the dependencies added to the composer.json suggestions? (phpstan/phpstan is already suggested)
  • Is the doc/tasks.md file updated? (PHPStan is already listed, no change needed)
  • Are the task parameters documented? (no new parameters, the auto-fixing behaviour is documented in doc/tasks/phpstan.md)
  • Is the task registered in the tasks.yml file? (already registered)
  • Does the task contains phpunit tests? (test/Unit/Task/PhpStanTest.php covers fixable, non-fixable and fixable-ignored output and the fix command)
  • Is the configuration having logical allowed types? (configuration unchanged)
  • Does the task run in the correct context? (unchanged: pre-commit and run)
  • Is the run() method readable?
  • Is the run() method using the configuration correctly? (the fix command reuses the same arguments)
  • Are all CI services returning green?

PHPStan 2.3.5 marks errors it can fix automatically and suggests to run
it again with --fix. When the analysis reports such errors, the phpstan
task now returns a fixable result that reruns the same command with
--fix, like the other fixable tasks.

The fixer is only offered when PHPStan reports fixable errors: without
them, "phpstan analyse --fix" fails with "No fixable errors found".

@veewee veewee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR.

I added some small remarks.

About the YAML issue: another PR got opened for this: #1240
Lets maybe fix it in that (foucssed) PR instead of this one.

Comment thread src/Task/PhpStan.php
private function hasFixableErrors(Process $process): bool
{
return 1 === preg_match(
'/Run PHPStan again with --fix(?![\w-])/',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if this is the best way to detect it is fixable or not.
If they change the text, this will fail.

Most other tasks similar to this one just try to fix the issue without checking if they actually can. So this might be a good addition, i'm just wondering the "how" is good enough or could be detected differently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. I considered always offering the fixer like ECS/Rector, but for PHPStan that has two downsides:

  1. Unlike ECS/Rector, most PHPStan errors (e.g. type errors) can't be fixed automatically. GrumPHP would ask "I can fix some stuff automatically" on almost every failing run, and phpstan analyse --fix then exits with 1 ("No fixable errors found"), so the fixer reports ✘.
  2. Up to PHPStan 2.1, --fix launches PHPStan Pro (it downloads the Pro executable and starts it) instead of fixing.

If PHPStan rewords the hint, hasFixableErrors() returns false and the task behaves exactly like today (failed result without fixer), so it degrades gracefully instead of breaking.

There is a structured alternative: with --error-format=json, every fixable message has "fixable": true. The options I see:

  • A: keep the hint detection as it is.
  • B: on failure, run a second analyse --error-format=json only to detect fixable errors. This keeps PHPStan's table output, but costs an extra run (which should mostly be served from the result cache).
  • C: switch the task to --error-format=json and render the output in GrumPHP, like the phpcs task does with --report-json. No extra run, but PHPStan's own table output is lost.
  • D: always offer the fixer like ECS/Rector, with the downsides above.

Which one do you prefer? I'll adapt the PR accordingly.

private const FIXABLE_IGNORED_OUTPUT = <<<'TXT'
[ERROR] Found 1 error

🔧 1 ignored error can be fixed automatically. Run PHPStan again with --fix-ignored.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the difference between regular fixable and fixable ignored options?
Should we support both somehow?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--fix fixes the errors PHPStan reports. --fix-ignored fixes errors that are currently ignored via the baseline, ignoreErrors or @phpstan-ignore, and then removes the ignore entries that are no longer needed. That's mainly useful to shrink a baseline, e.g. after upgrading to PHPStan 2.3.5, where many baselined errors like method.missingOverride became auto-fixable.

I tried combining both (--fix --fix-ignored), but it doesn't work with GrumPHP yet: when only the changed files are passed to PHPStan (the default use_grumphp_paths: true), the ignored errors get fixed but the baseline entries stay, because PHPStan doesn't report unmatched ignores for a list of files, so its later fix rounds never remove them. The next full run (e.g. in CI) then fails with ignore.unmatched. The same happens with --fix-ignored alone.

That's a bug in PHPStan: I reported it in phpstan/phpstan#15704 and opened a fix in phpstan/phpstan-src#7036. Since ignored errors don't make the task fail anyway, this PR only uses --fix. Once the fix is released, adding --fix-ignored (e.g. as an opt-in option) could be a follow-up.

@Kanti
Kanti force-pushed the ccr-377c09f1-bxqh6w branch from 1f28655 to 4952a67 Compare November 11, 2026 15:08

@Kanti Kanti left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review! Agreed, #1240 fixes this more thoroughly, so I've dropped the YAML commit from this PR. Until #1240 is merged, the highest jobs here will fail again on the Psalm errors in YamlLintError.

private const FIXABLE_IGNORED_OUTPUT = <<<'TXT'
[ERROR] Found 1 error

🔧 1 ignored error can be fixed automatically. Run PHPStan again with --fix-ignored.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--fix fixes the errors PHPStan reports. --fix-ignored fixes errors that are currently ignored via the baseline, ignoreErrors or @phpstan-ignore, and then removes the ignore entries that are no longer needed. That's mainly useful to shrink a baseline, e.g. after upgrading to PHPStan 2.3.5, where many baselined errors like method.missingOverride became auto-fixable.

I tried combining both (--fix --fix-ignored), but it doesn't work with GrumPHP yet: when only the changed files are passed to PHPStan (the default use_grumphp_paths: true), the ignored errors get fixed but the baseline entries stay, because PHPStan doesn't report unmatched ignores for a list of files, so its later fix rounds never remove them. The next full run (e.g. in CI) then fails with ignore.unmatched. The same happens with --fix-ignored alone.

That's a bug in PHPStan: I reported it in phpstan/phpstan#15704 and opened a fix in phpstan/phpstan-src#7036. Since ignored errors don't make the task fail anyway, this PR only uses --fix. Once the fix is released, adding --fix-ignored (e.g. as an opt-in option) could be a follow-up.

Comment thread src/Task/PhpStan.php
private function hasFixableErrors(Process $process): bool
{
return 1 === preg_match(
'/Run PHPStan again with --fix(?![\w-])/',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. I considered always offering the fixer like ECS/Rector, but for PHPStan that has two downsides:

  1. Unlike ECS/Rector, most PHPStan errors (e.g. type errors) can't be fixed automatically. GrumPHP would ask "I can fix some stuff automatically" on almost every failing run, and phpstan analyse --fix then exits with 1 ("No fixable errors found"), so the fixer reports ✘.
  2. Up to PHPStan 2.1, --fix launches PHPStan Pro (it downloads the Pro executable and starts it) instead of fixing.

If PHPStan rewords the hint, hasFixableErrors() returns false and the task behaves exactly like today (failed result without fixer), so it degrades gracefully instead of breaking.

There is a structured alternative: with --error-format=json, every fixable message has "fixable": true. The options I see:

  • A: keep the hint detection as it is.
  • B: on failure, run a second analyse --error-format=json only to detect fixable errors. This keeps PHPStan's table output, but costs an extra run (which should mostly be served from the result cache).
  • C: switch the task to --error-format=json and render the output in GrumPHP, like the phpcs task does with --report-json. No extra run, but PHPStan's own table output is lost.
  • D: always offer the fixer like ECS/Rector, with the downsides above.

Which one do you prefer? I'll adapt the PR accordingly.

@Kanti

Kanti commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

@veewee FYI, Ondřej's answer on the PHPStan issue: phpstan/phpstan#15704 (comment)

I'd like you to discourage from incomplete (partial) runs as much as possible. You should always analyse the entire project, it will most likely be even faster than your partial runs

(see also https://phpstan.org/blog/why-you-should-always-analyse-whole-project)

I think we should take this into account in GrumPHP. By default, the phpstan task only passes the changed files to PHPStan (use_grumphp_paths: true). A partial run can miss errors that a change causes in other files, e.g. a changed method signature that breaks a caller in a file that isn't part of the commit. It's also the reason why --fix-ignored leaves baseline entries behind. Thanks to the result cache, a full run is usually not slower.

Should we analyse the whole project by default, as configured in phpstan.neon? Ideally the task would still be skipped when no PHP files changed: with use_grumphp_paths: false it currently runs on every commit. Since this changes the default behaviour, it might be something for the next major version, or we could start by recommending it in the docs.

I'd suggest handling this in a separate PR, so this one stays focused on --fix. What do you think?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants