Found while validating the fork on providers-api.lawnstarter.com for PE-80051 (adoption epic).
Version: graphify 0.9.40, fork origin/v8 = 5e9d95c11837aeabbbb5d90cb45544f49dc58ca4.
Summary
The documented limit is that service-locator calls "never resolve" — no static tool can bind app(X::class). That would be acceptable: a missing edge is a known unknown.
What actually happens is worse. app(Handler::class)->handle() inside a class that itself has a handle() method produces a calls edge to its own handle(), not to Handler::handle(). The graph asserts a call that does not exist, and the real callee gets no incoming edge at all.
A consumer cannot detect this: the edge looks exactly like a legitimately resolved call.
Minimal reproduction
// src/Handler.php
<?php
namespace App;
class Handler
{
public function handle(): string { return 'handled'; }
}
// src/Job.php
<?php
namespace App;
class Job
{
public function handle(): string { return 'job-handle'; }
public function failed(): string { return app(Handler::class)->handle(); }
}
$ graphify update .
$ python3 -c "…dump calls edges…"
.failed() src/Job.php -> .handle() TARGET_SRC=src/Job.php L6 | at src/Job.php L8
The edge from Job::failed() (L8) points at Job::handle() (L6, same file) instead of Handler::handle(). Handler has no incoming calls edge whatsoever.
Expected: either no edge at all (honest refusal), or an indirect_call edge to Handler::handle().
Impact on the real repo
Reproduces in both Laravel job classes, in exactly the pattern Laravel encourages for failed() handlers:
| Call site |
Real callee |
Edge graphify emitted |
EvaluateAssessmentJob::failed() L46 — app(HandleFailedAssessmentEvaluation::class)->handle(...) |
HandleFailedAssessmentEvaluation::handle |
EvaluateAssessmentJob::handle() (L39, own class) |
ScoreAssessmentAnswerJob::failed() L49 — app(HandleFailedAssessmentScoring::class)->handle(...) |
HandleFailedAssessmentScoring::handle |
ScoreAssessmentAnswerJob::handle() (L42, own class) |
Neither HandleFailedAssessmentEvaluation nor HandleFailedAssessmentScoring has a single incoming calls edge in the graph — so "what breaks if I change the failure handler?" returns a false negative, while "what does failed() call?" returns a false positive pointing back into the job itself.
Suggested fix
When the receiver expression is a container call (app(...), resolve(...), App::make(...)), suppress method-name resolution against the enclosing class scope. If the ::class argument is a literal, an indirect_call edge to that class's method would be recoverable and strictly better; otherwise emit nothing.
Found while validating the fork on
providers-api.lawnstarter.comfor PE-80051 (adoption epic).Version:
graphify 0.9.40, forkorigin/v8=5e9d95c11837aeabbbb5d90cb45544f49dc58ca4.Summary
The documented limit is that service-locator calls "never resolve" — no static tool can bind
app(X::class). That would be acceptable: a missing edge is a known unknown.What actually happens is worse.
app(Handler::class)->handle()inside a class that itself has ahandle()method produces acallsedge to its ownhandle(), not toHandler::handle(). The graph asserts a call that does not exist, and the real callee gets no incoming edge at all.A consumer cannot detect this: the edge looks exactly like a legitimately resolved call.
Minimal reproduction
The edge from
Job::failed()(L8) points atJob::handle()(L6, same file) instead ofHandler::handle().Handlerhas no incomingcallsedge whatsoever.Expected: either no edge at all (honest refusal), or an
indirect_calledge toHandler::handle().Impact on the real repo
Reproduces in both Laravel job classes, in exactly the pattern Laravel encourages for
failed()handlers:EvaluateAssessmentJob::failed()L46 —app(HandleFailedAssessmentEvaluation::class)->handle(...)HandleFailedAssessmentEvaluation::handleEvaluateAssessmentJob::handle()(L39, own class)ScoreAssessmentAnswerJob::failed()L49 —app(HandleFailedAssessmentScoring::class)->handle(...)HandleFailedAssessmentScoring::handleScoreAssessmentAnswerJob::handle()(L42, own class)Neither
HandleFailedAssessmentEvaluationnorHandleFailedAssessmentScoringhas a single incomingcallsedge in the graph — so "what breaks if I change the failure handler?" returns a false negative, while "what doesfailed()call?" returns a false positive pointing back into the job itself.Suggested fix
When the receiver expression is a container call (
app(...),resolve(...),App::make(...)), suppress method-name resolution against the enclosing class scope. If the::classargument is a literal, anindirect_calledge to that class's method would be recoverable and strictly better; otherwise emit nothing.