Skip to content

fix(checker): a used trait's abstract methods are requirements an inherited method satisfies - #2293

Open
phcorp wants to merge 2 commits into
illegalstudio:mainfrom
phcorp:fix/trait-method-requirements-in-class-schema
Open

phcorp wants to merge 2 commits into
illegalstudio:mainfrom
phcorp:fix/trait-method-requirements-in-class-schema

Conversation

@phcorp

@phcorp phcorp commented Nov 8, 2026 •

Copy link
Copy Markdown

Summary

Trait flattening merges a trait's abstract method into the class like any other member. The class schema therefore read abstract function invalidate() as if the class itself declared it. As a result, three valid PHP forms failed when an inherited method satisfied the requirement:

  • Cannot override final method Widget::invalidate when the inherited method is final.
  • Cannot make concrete method abstract: Panel::invalidate when it is not final.
  • Panel::rowPath() has #[\Override] attribute, but no matching parent method was found. PHP 8.3+ accepts #[\Override] on a method that implements a used trait's abstract method.

With this PR, a used trait's abstract method that an inherited implementation already satisfies adds nothing to the schema. The inherited signature is still checked against the requirement, as PHP does. #[\Override] also finds its target in a trait requirement.

Repro

<?php
abstract class Widget {
    final public function invalidate(): string { return "inv"; }
    public static function make(): string { return "made"; }
}
trait Rows {
    abstract public function invalidate(): string;          // satisfied by Widget's final method
    abstract public static function make(): string;         // satisfied by Widget's static method
    abstract protected function rowPath(int $i): ?string;
    public function rows(): string { return $this->invalidate() . ":" . static::make() . ":" . $this->rowPath(2); }
}
final class Panel extends Widget {
    use Rows;
    #[\Override]                                            // implements Rows' requirement
    protected function rowPath(int $i): ?string { return "r$i"; }
}
echo (new Panel())->rows(), "\n";
output
main error: Cannot override final method Widget::invalidate
this PR inv:made:r2
php 8.5 inv:made:r2

An incompatible inherited method is still rejected:

class P { public function foo(string $x): int { return 1; } }
trait T { abstract public function foo(int $x): string; }
final class C extends P { use T; }
output
main Cannot override method C::foo with incompatible …
first commit of this PR accepted (prints 1): skipping the requirement also skipped the signature check
this PR (head) Cannot narrow parameter $x when implementing trait method: C::foo
php 8.5 Declaration of P::foo(string $x): int must be compatible with T::foo(int $x): string

How it works

flowchart TD
    F["flatten traits<br/>(abstract trait methods merged into class)"] --> COL["collect_trait_method_requirements<br/>required = abstract keys of used traits (nested)<br/>declared = keys written in the class body"]
    COL --> M{"apply method:<br/>abstract, from a trait (required ∖ declared),<br/>and an inherited concrete implementation<br/>of the same kind exists?"}
    M -- no --> N["normal path: final / kind / visibility /<br/>override-signature / re-abstraction checks"]
    M -- yes --> V["validate_inherited_implementation<br/>(params may only widen, declared return honoured)"]
    V -- ok --> SKIP["skip: requirement adds nothing"]
    V -- incompatible --> ERR[error]
    N --> O{"#[\Override] with no parent/interface target?"}
    O -- "key is a trait requirement" --> OK[accepted]
    O -- otherwise --> ERR2["no matching parent method"]
Loading
  • collect_trait_method_requirements runs right after flattening, next to collect_trait_default_origins. For each trait-using class it records which method keys the used traits require (through nested use) and which keys the class body declares. The table is stored as Checker::trait_method_requirements.
  • inherited_method_satisfies_requirement is called at the top of apply_instance_method and apply_static_method with the inherited signature of the same kind. It applies only when that kind has a concrete implementation (method_impl_classes / static_method_impl_classes). A static/instance mismatch therefore still takes the normal path and reports Cannot change method kind.
  • validate_inherited_implementation reuses validate_abstract_trait_signature, the helper enums already use for trait requirements, and adds a declared-return check. Late-static (static) requirement returns are exempt.
  • satisfies_trait_requirement lets #[\Override] target a trait requirement, alongside parent and interface methods.
  • If the class body re-declares an inherited concrete method abstract itself (declared contains the key), it is still an error, as in PHP.

Tests

  • test_trait_requirements_satisfied_by_inherited_methods_and_override (tests/codegen/oop/traits.rs#L189): a final instance method, a static method, and a protected requirement implemented with #[\Override], all reached through a nested trait use. It prints inv:made:r2, as php 8.5 does (ELEPHC_PHP_CHECK=1). It fails on main.
  • test_error_trait_using_class_cannot_make_inherited_method_abstract (#L78): a trait-using class may still not re-declare an inherited concrete method abstract.
  • test_error_inherited_method_must_match_trait_requirement_signature (#L92): an inherited instance method that narrows a parameter, and an inherited static method with an incompatible return type, are both rejected. It fails on the first commit of this PR.
  • test_error_override_in_trait_using_class_requires_a_target (#L114): #[\Override] still needs some target.

Probed by hand against php 8.5 (all match): a requirement satisfied through a grandparent, in a namespace, in braced namespaces across use Lib\Rows, with use Rows { inv as protected; }, and with a widened inherited signature (foo(mixed $x = null, int $y = 2) for foo(int $x), plus a static return). Instance/static mismatches with the parent are rejected (Cannot change method kind).

Runs (local; fork CI does not run):

  • ELEPHC_PHP_CHECK=1 cargo test --test codegen_tests trait: 101 passed
  • cargo test --test codegen_tests override: 117 passed, 1 ignored
  • cargo test --test error_tests trait: 116 passed. override: 25 passed. abstract: 23 passed.
  • cargo build: no new warnings (the fetch_update warning in elephc-magician is already on main). git diff --check is clean.

Note

CI status: this PR comes from a fork, so the CI, PDO Live Databases and PHP extension host SDK workflows are waiting for maintainer approval (action_required). The focused tests above ran locally on macOS arm64, and the codegen tests were also compared with PHP 8.5.12 (ELEPHC_PHP_CHECK=1). This is a front-end change (parser/checker only) with no target-specific code.

Limits

  • Parent private method: class P { private function foo() {} } class C extends P { use T; } (with T requiring foo) is rejected with Concrete class C must implement abstract method C::foo. php 8.5 compiles it and only fails when the method is called (Call to private method P::foo() from scope C). elephc is stricter than php here, which is acceptable for an ahead-of-time compiler.
  • The requirement table is keyed by method name, not by (name, is_static), and it ignores insteadof exclusions. That only matters for #[\Override] acceptance in contrived cases, because mismatched kinds are rejected elsewhere.
  • Requirement-vs-own-method and requirement-vs-other-trait-method signatures are still not checked (main has the same gap). This PR checks only the inherited case.
  • src/types/checker/schema/classes/methods.rs grows from 484 to 538 lines. It stays one cohesive leaf (method schema rules), and the new logic lives in trait_requirements.rs.

Follow-ups

@github-actions github-actions Bot added area:types Touches type checking, inference, or compatibility. size:s Small pull request. type:fix Corrects broken or incompatible behavior. labels Nov 8, 2026
… it satisfies

Skipping a trait's abstract method satisfied by an inherited implementation also skipped the signature check, so an incompatible parent method was silently accepted where PHP reports 'Declaration of P::foo() must be compatible with T::foo()'. The inherited signature is now validated with the rules enums already apply to trait requirements: parameters may only widen and a declared return type must be honoured.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:types Touches type checking, inference, or compatibility. size:s Small pull request. type:fix Corrects broken or incompatible behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant