Skip to content

Do not report a private method overriding a private trait method as unused - #6191

Merged
staabm merged 4 commits into
phpstan:2.3.xfrom
Jean-Beru:fix-12201-private-method-overriding-trait
Sep 28, 2026
Merged

staabm merged 4 commits into
phpstan:2.3.xfrom
Jean-Beru:fix-12201-private-method-overriding-trait

Conversation

@Jean-Beru

Copy link
Copy Markdown
Contributor

Fixes phpstan/phpstan#12201

Why

A class member takes precedence over the member of the same name coming from a used trait. So a private method redeclared in the class is the one the trait's own methods call.

UnusedPrivateMethodRule sees those call sites only when the trait is part of the analysed files. In a project whose paths exclude its dependencies, ClassMethodsNode::getMethodCalls() collects no call at all. The class method is then reported as unused.

The canonical case is a Symfony application. The recipe generates a Kernel that redeclares KernelTrait::getAllowedEnvs(), and the trait lives in vendor/.

// vendor/symfony/dependency-injection/Kernel/KernelTrait.php, not analysed
trait KernelTrait
{
	private function getAllowedEnvs(): array
	{
		return [];
	}

	protected function getKernelParameters(): array
	{
		// ...
		if (!$knownEnvs = array_flip($this->getAllowedEnvs())) {
		// ...
	}
}
// src/Kernel.php, analysed
class Kernel extends BaseKernel
{
	use MicroKernelTrait; // uses KernelTrait

	/**
	 * @return list<string>
	 */
	private function getAllowedEnvs(): array // reported as unused
	{
		return ['prod', 'dev', 'test'];
	}
}

Every new Symfony 8.1 project hits this from level 4 up. ignoreErrors is the only way out.

What

A private method is skipped when a used trait declares a private method of the same name.

getTraits() is enough without recursion. PHP flattens trait composition. getAllowedEnvs() comes from the nested KernelTrait, and the reflection of MicroKernelTrait already reports it.

@Jean-Beru Jean-Beru changed the title Do not report a private method overriding a private trait method as u… Do not report a private method overriding a private trait method as unused Aug 6, 2026
Comment thread src/Rules/DeadCode/UnusedPrivateMethodRule.php
@staabm
staabm force-pushed the fix-12201-private-method-overriding-trait branch from 16238a7 to c718210 Compare August 13, 2026 08:46
@staabm

staabm commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

do we have a similar problem for private constants (UnusedPrivateConstantRule) or properties (UnusedPrivatePropertyRule)?

@VincentLanglet

Copy link
Copy Markdown
Contributor

do we have a similar problem for private constants (UnusedPrivateConstantRule) or properties (UnusedPrivatePropertyRule)?

We do.

@Jean-Beru Could you do the same for those rules ?

@staabm

staabm commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

please rebase onto 2.3.x - this PR is hundreds of commits behind

…nused

A class member takes precedence over the member of the same name coming
from a used trait, so a private method redeclared in the class is what the
trait's own methods call. The rule sees those call sites only when the
trait is part of the analysed files: analysing a project whose paths do not
include its dependencies leaves ClassMethodsNode::getMethodCalls() with no
call at all, and the class method is reported as unused.

The canonical case is a Symfony application, where the framework recipe
generates a Kernel redeclaring KernelTrait::getAllowedEnvs() while the
trait lives in vendor/, outside of the analysed paths.

Skip those methods. Nothing is left to distinguish an override that the
trait calls from one it does not, so a redeclared private method the trait
never calls is no longer reported either.

Fixes phpstan/phpstan#12201

Assisted-by: Claude Code:claude-opus-5
…tant as unused

PHP merges a class constant with the same-named constant of a used trait into a
single slot, so the trait's own methods fetch the class' declaration. The rule sees
those fetches only when the trait's body is traversed, which NodeScopeResolver does
for analysed files only: analysing a project whose paths do not include its
dependencies leaves ClassConstantsNode with no fetch at all, and the class constant
is reported as unused.

Skip those constants, but only when no ClassConst node was gathered from a trait
body for that name. Such a node proves the trait was traversed and its fetches are
visible, so a redeclaration whose trait lives inside the analysed paths keeps being
judged on what the code actually does.

The residual cost is a constant redeclared from a trait outside the analysed paths
that the trait never fetches: it is no longer reported, and nothing is left to
distinguish it from one the trait does fetch.

Assisted-by: Claude Code:claude-opus-5
…erty as unused

PHP merges a class property with the same-named property of a used trait into a
single slot, so the trait's own methods read and write the class' declaration. The
rule sees those usages only when the trait's body is traversed, which
NodeScopeResolver does for analysed files only: analysing a project whose paths do
not include its dependencies leaves ClassPropertiesNode with no usage at all, and
the class property is reported as never read.

Skip those properties, but only when no property node declared in a trait was
gathered for that name. Such a node proves the trait was traversed and its usages
are visible, so a redeclaration whose trait lives inside the analysed paths keeps
being judged on what the code actually does.

The residual cost is a property redeclared from a trait outside the analysed paths
that the trait never reads: it is no longer reported, and nothing is left to
distinguish it from one the trait does read.

Assisted-by: Claude Code:claude-opus-5
# only the PHP development headers are needed (php-config --includes);
# the extension is not built here
- name: "Install PHP"
uses: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240" # 2.37.2
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0

- name: "Install PHP"
uses: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240" # 2.37.2

# the differential tests compare the native results against the PHP
# twins, so the PHP side has to be installed
- uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0
- name: "Compile phpstan_turbo with strict warnings"
# The training run of `make pgo` (bin/pgo-train.sh) runs bin/phpstan on
# the checkout, so the dependencies must be in place before the build.
- uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0

- name: "Install PHP"
if: ${{ !matrix.container }}
uses: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240" # v2.37.2
MATRIX_TS: ${{ matrix.ts }}
run: bash .github/scripts/install-php86-windows.sh

- uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0
# of ORIGIN_RUN_ID repeated in both). The tests of every binary, reused
# or compiled, run in the turbo-differential jobs, so the commit job,
# which needs the binaries, does not wait for them.
runs-on: ${{ fromJSON(needs.turbo-origins.outputs.origins || '{}')[format('{0}-{1}-php{2}', matrix.target.artifact, matrix.target.name, matrix.php-version)] && 'ubuntu-latest' || matrix.target.runs-on }}
# container via docker exec — native arm64, no QEMU. The steps mirror
# the musl leg of turbo-compile one for one, including the reuse of a
# binary compiled in an earlier run, whose leg runs on ubuntu-latest.
runs-on: ${{ fromJSON(needs.turbo-origins.outputs.origins || '{}')[format('phpstan_turbo-linux-musl-arm64-php{0}', matrix.php-version)] && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
# PHP 8.6's vs18 development packs need the VS2026 toolchain.
runs-on: ${{ matrix.php-version == '8.6' && 'windows-2025-vs2026' || 'windows-2022' }}
# A leg reusing a binary (see turbo-compile) runs on ubuntu-latest.
runs-on: ${{ fromJSON(needs.turbo-origins.outputs.origins || '{}')[format('phpstan_turbo-windows-x86_64-php{0}{1}', matrix.php-version, matrix.ts == 'zts' && '-zts' || '')] && 'ubuntu-latest' || (matrix.php-version == '8.6' && 'windows-2025-vs2026' || 'windows-2022') }}
if: env.ORIGIN_RUN_ID != ''
run: |
echo "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$ORIGIN_RUN_ID" | tee turbo-reused.txt
echo "DLL_PATH=turbo-ext/php_phpstan_turbo.dll" >> "$GITHUB_ENV"
if command -v cygpath > /dev/null; then
EXT="$(cygpath -w "$EXT")"
fi
echo "TURBO_DLL=$EXT" >> "$GITHUB_ENV"
needs:
- turbo-compile
- turbo-compile-musl-arm64
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
path: phpstan-dist
token: ${{ secrets.PHPSTAN_BOT_TOKEN }}
ref: 2.2.x
ref: 2.3.x
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: 2.2.x
ref: 2.3.x
Comment on lines +65 to +105
turbo-lint:
name: "Turbo Extension Lint"

runs-on: "ubuntu-latest"
timeout-minutes: 30

steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit

- name: "Checkout"
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0

# only the PHP development headers are needed (php-config --includes);
# the extension is not built here
- name: "Install PHP"
uses: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240" # 2.37.2
with:
coverage: "none"
php-version: "8.5"

# The pinned major version is read from the Makefile rather than
# repeated here, so CI and a local run gate on the same checks — the
# target refuses to run with any other version. Ubuntu ships an older
# clang-tidy than the pin, so it comes from apt.llvm.org.
- name: "Install the pinned clang-tidy"
run: |
CLANG_TIDY_VERSION="$(make -s -C turbo-ext print-clang-tidy-version)"
echo "pinned clang-tidy: $CLANG_TIDY_VERSION"
# only the codename: /etc/os-release also defines VERSION
CODENAME="$(. /etc/os-release && echo "$VERSION_CODENAME")"
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc > /dev/null
echo "deb https://apt.llvm.org/${CODENAME}/ llvm-toolchain-${CODENAME}-${CLANG_TIDY_VERSION} main" \
| sudo tee "/etc/apt/sources.list.d/llvm-${CLANG_TIDY_VERSION}.list"
sudo apt-get update
sudo apt-get install -y "clang-tidy-${CLANG_TIDY_VERSION}"

- name: "Lint the native sources"
run: make lint-turbo
Comment on lines +77 to +81
- name: "Checkout"
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0

# only the PHP development headers are needed (php-config --includes);
# the extension is not built here
Comment on lines +107 to +133
turbo-sanitize:
name: "Turbo Extension Sanitizers"

runs-on: "ubuntu-latest"
timeout-minutes: 30

steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit

- name: "Checkout"
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0

- name: "Install PHP"
uses: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240" # 2.37.2
with:
coverage: "none"
php-version: "8.5"

# the differential tests compare the native results against the PHP
# twins, so the PHP side has to be installed
- uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0

- name: "Differential tests under UndefinedBehaviorSanitizer"
run: make sanitize-turbo
Comment on lines +119 to +120
- name: "Checkout"
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
# of ORIGIN_RUN_ID repeated in both). The tests of every binary, reused
# or compiled, run in the turbo-differential jobs, so the commit job,
# which needs the binaries, does not wait for them.
runs-on: ${{ fromJSON(needs.turbo-origins.outputs.origins || '{}')[format('{0}-{1}-php{2}', matrix.target.artifact, matrix.target.name, matrix.php-version)] && 'ubuntu-latest' || matrix.target.runs-on }}
env:
PHP_MINOR: ${{ matrix.php-version }}
TURBO_ARTIFACT: "${{ matrix.target.artifact }}-${{ matrix.target.name }}-php${{ matrix.php-version }}"
ORIGIN_RUN_ID: ${{ fromJSON(needs.turbo-origins.outputs.origins || '{}')[format('{0}-{1}-php{2}', matrix.target.artifact, matrix.target.name, matrix.php-version)] }}
# container via docker exec — native arm64, no QEMU. The steps mirror
# the musl leg of turbo-compile one for one, including the reuse of a
# binary compiled in an earlier run, whose leg runs on ubuntu-latest.
runs-on: ${{ fromJSON(needs.turbo-origins.outputs.origins || '{}')[format('phpstan_turbo-linux-musl-arm64-php{0}', matrix.php-version)] && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
env:
PHP_MINOR: ${{ matrix.php-version }}
TURBO_ARTIFACT: "phpstan_turbo-linux-musl-arm64-php${{ matrix.php-version }}"
ORIGIN_RUN_ID: ${{ fromJSON(needs.turbo-origins.outputs.origins || '{}')[format('phpstan_turbo-linux-musl-arm64-php{0}', matrix.php-version)] }}
# PHP 8.6's vs18 development packs need the VS2026 toolchain.
runs-on: ${{ matrix.php-version == '8.6' && 'windows-2025-vs2026' || 'windows-2022' }}
# A leg reusing a binary (see turbo-compile) runs on ubuntu-latest.
runs-on: ${{ fromJSON(needs.turbo-origins.outputs.origins || '{}')[format('phpstan_turbo-windows-x86_64-php{0}{1}', matrix.php-version, matrix.ts == 'zts' && '-zts' || '')] && 'ubuntu-latest' || (matrix.php-version == '8.6' && 'windows-2025-vs2026' || 'windows-2022') }}
# uses (ships bison and the unix tools phpize/configure expect).
PHP_SDK_COMMIT: "c73faaf1cce914e2fc04da5587132f8f425996af" # php-sdk-2.7.1
TURBO_ARTIFACT: "phpstan_turbo-windows-x86_64-php${{ matrix.php-version }}${{ matrix.ts == 'zts' && '-zts' || '' }}"
ORIGIN_RUN_ID: ${{ fromJSON(needs.turbo-origins.outputs.origins || '{}')[format('phpstan_turbo-windows-x86_64-php{0}{1}', matrix.php-version, matrix.ts == 'zts' && '-zts' || '')] }}
@Jean-Beru
Jean-Beru changed the base branch from 2.2.x to 2.3.x September 28, 2026 08:26
@Jean-Beru
Jean-Beru force-pushed the fix-12201-private-method-overriding-trait branch from d9bc5da to d171b76 Compare September 28, 2026 08:26
@Jean-Beru

Copy link
Copy Markdown
Contributor Author

do we have a similar problem for private constants (UnusedPrivateConstantRule) or properties (UnusedPrivatePropertyRule)?

We do.

@Jean-Beru Could you do the same for those rules ?

Good catch. I added two commits to fix constants and properties. I also rebased on 2.3.x.

Should I have to add a warning somewhere to indicate that baselines has to be updated following this fix ?

@VincentLanglet

Copy link
Copy Markdown
Contributor

Should I have to add a warning somewhere to indicate that baselines has to be updated following this fix ?

No, it's normal that people have to update the baseline/ignored-error when we fix a bug.

@@ -0,0 +1,42 @@
<?php declare(strict_types = 1);

namespace Bug12201Property;

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.

please add a comment what this file is supposed to be and why itself is not analyzed


declare(strict_types = 1);

namespace Bug12201Constant;

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.

please add a comment what this file is supposed to be and why itself is not analyzed

@@ -0,0 +1,27 @@
<?php declare(strict_types = 1);

namespace Bug12201;

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.

please add a comment what this file is supposed to be and why itself is not analyzed

The three *-traits.php fixtures stand for a dependency living outside of the
analysed paths, which is the whole point of the reproducers: PHPStan never
traverses the trait bodies, so the trait's own calls, fetches and property
usages stay invisible. Only the consumer files said so.

Assisted-by: Claude Code:claude-opus-5
@staabm

staabm commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

well done - thank you!

@staabm
staabm merged commit 973edb7 into phpstan:2.3.x Sep 28, 2026
899 of 912 checks passed
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.

"method unused" when overriding private methode of a trait

4 participants