Skip to content

PHP: method calls through a class property ($this->dep->method()) never resolve — constructor-injected dependencies produce no call edges #1220

Description

@w0lan

Summary

In PHP, a method call whose receiver is a class property$this->greeter->greet() — never produces a calls edge, regardless of whether the property is typed with a concrete class or an interface, and regardless of declaration style (PHP 8 constructor property promotion or a classic typed property assigned in the constructor).

Receiver-type inference added in #1108 (locals) and #1125 (parameters) covers the other shapes: on 1.3.0, local-variable receivers ($g = new Greeter(); $g->greet()) and typed-parameter receivers (function run(Greeter $g) { $g->greet(); }) both resolve correctly. Class-property receivers are the missing case — and in dependency-injection codebases (Symfony, Laravel constructor injection) they are the dominant call shape: nearly every cross-class call goes through a constructor-injected property.

Evidence

Minimal repro — 4 call sites of Greeter::greet(), identical except for how the receiver is obtained:

// src/Greeter.php
class Greeter {
    public function greet(string $n): string { return "hi $n"; }
}

// src/UserViaNew.php — local variable
class UserViaNew {
    public function run(): string { $g = new Greeter(); return $g->greet('new'); }
}

// src/UserViaParam.php — typed parameter
class UserViaParam {
    public function run(Greeter $greeter): string { return $greeter->greet('param'); }
}

// src/UserViaDi.php — promoted constructor property
class UserViaDi {
    public function __construct(private readonly Greeter $greeter) {}
    public function run(): string { return $this->greeter->greet('di'); }
}

// src/UserViaClassicProp.php — classic typed property
class UserViaClassicProp {
    private Greeter $greeter;
    public function __construct(Greeter $greeter) { $this->greeter = $greeter; }
    public function run(): string { return $this->greeter->greet('classic'); }
}

codegraph init && codegraph callers greet (v1.3.0, also reproduced on 1.2.0):

Callers of "greet" (2):

method      run
  src/UserViaNew.php:3

method      run
  src/UserViaParam.php:3
Receiver shape Resolves?
local variable from new ✅ (#1108)
typed function parameter
promoted constructor property (private readonly Greeter $greeter)
classic typed property (private Greeter $greeter; + constructor assignment)

Same-class calls ($this->method()) and static calls resolve fine; this is specifically the property-receiver hop.

Why it matters

Measured on six real PHP codebases (340 → 9 200 files, PHP 7.1 → 8.5, Symfony 3.2 → 8.0): in DI-style code every cross-class production call goes through constructor-injected properties, so codegraph_callers returned 0 of 13 verified production call sites across the methods we first sampled, while happily returning the unit-test call sites (local new receivers). The result is worse than an empty answer: a method with production callers looks test-only/dead, and codegraph_impact reports only same-name declarations, so blast-radius on a signature change misses every real consumer. (Happy to share the detailed measurement protocol.)

I have a working fix (PR incoming) that follows the existing receiver-type-inference architecture; validated on those six codebases it takes a sampled repository method from 0/7 to 7/7 production callers with 49/49 manually verified call edges and no false positives — details in the PR.

Root cause (from reading main)

  1. Extraction (src/extraction/tree-sitter.ts, extractCall): for member_call_expression the receiver becomes the node's raw text with only the leading $ stripped, so $this->greeter->greet() is recorded as the reference this->greeter.greet. Java has a dedicated unwrap for exactly this shape — field_access with a this object unwraps to the bare field name, with the comment "so the resolver can then look up in the enclosing class's field declarations" — but the condition only matches Java's field_access; PHP's member_access_expression never takes that path.
  2. Resolution (src/resolution/): nothing splits ->, and nothing consults class property declarations for PHP — so this->greeter is an unmatchable receiver string and the reference is dropped.

The type information is statically available: promoted properties carry it in the constructor signature, classic typed properties (PHP 7.4+) in the declaration, and the PHP extractor already collects property_declaration nodes (fieldTypes in src/extraction/languages/php.ts) — it's just not used for receiver typing.

Relationship to existing work

Environment: CodeGraph 1.2.0 and 1.3.0 (npm), macOS arm64, PHP grammar as bundled.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions