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)
- 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.
- 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.
Summary
In PHP, a method call whose receiver is a class property —
$this->greeter->greet()— never produces acallsedge, 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:codegraph init && codegraph callers greet(v1.3.0, also reproduced on 1.2.0):newprivate readonly Greeter $greeter)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_callersreturned 0 of 13 verified production call sites across the methods we first sampled, while happily returning the unit-test call sites (localnewreceivers). The result is worse than an empty answer: a method with production callers looks test-only/dead, andcodegraph_impactreports 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)
src/extraction/tree-sitter.ts,extractCall): formember_call_expressionthe receiver becomes the node's raw text with only the leading$stripped, so$this->greeter->greet()is recorded as the referencethis->greeter.greet. Java has a dedicated unwrap for exactly this shape —field_accesswith athisobject 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'sfield_access; PHP'smember_access_expressionnever takes that path.src/resolution/): nothing splits->, and nothing consults class property declarations for PHP — sothis->greeteris 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_declarationnodes (fieldTypesinsrc/extraction/languages/php.ts) — it's just not used for receiver typing.Relationship to existing work
this.<field>.method()resolution on the field's declared type for TS/JS — this issue is the PHP counterpart.Environment: CodeGraph 1.2.0 and 1.3.0 (npm), macOS arm64, PHP grammar as bundled.