Found while validating the fork on messaging-api.lawnstarter.com for PE-80058 (adoption epic). This is the one failing shape of the four graded there.
Version: graphify 0.9.40, fork origin/v8 = 5e9d95c11837aeabbbb5d90cb45544f49dc58ca4.
Related but distinct from #76: that issue is about app(X::class)->method() emitting a wrong edge when the enclosing class has a same-named method. This one is about the listens-to shape having no entry point at all — even a correct receiver edge would not make it answerable, because the dispatch key is never a node.
Summary
The spike graded four shapes; what listens to event X? is the one this repo fails. Two independent gaps combine to make the shape unanswerable for string-keyed dispatch tables, which is how both Laravel HTTP routing and this service's webhook fan-out are written:
- The dispatch key is not a node. No node exists for the event string
'message.new', nor for the route path '/webhooks/stream'. There is nothing to name in a query/explain/affected argument, so the question cannot be asked, let alone answered.
- The class-string callable emits no method-level edge.
Route::post('/path', [Controller::class, 'handle']) yields only a file-level imports edge. The handle() method has no incoming edge, so affected "Controller.handle()" reports nothing inbound from the routing layer.
Combined with the app(X::class) refusal, the entire event-routing table of this service is invisible: an agent asking "who handles message.new?" gets either nothing or an undiscriminated 310-node BFS dump.
Minimal reproduction
// app/Handler.php
<?php
namespace App;
class Handler
{
public function handle(array $payload): void {}
}
// app/Router.php
<?php
namespace App;
use App\Handler;
class Router
{
public function route(string $event, array $payload): void
{
match ($event) {
'message.new' => app(Handler::class)->handle($payload), // <- no edge
default => null,
};
}
public function routeInjected(Handler $handler, array $payload): void
{
$handler->handle($payload); // <- resolves fine
}
}
// app/Routes.php
<?php
namespace App;
use App\Handler;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/stream', [Handler::class, 'handle']); // <- no method edge
$ graphify update .
$ graphify affected "Handler" --relation calls --depth 1
Affected nodes for Handler
Relations: calls
Depth: 1
- .routeInjected() [calls] app/Router.php:L19
Only the typed-parameter call is found. Dumping every edge into Handler:
Router.php --imports--> Handler
.route() --references_constant--> Handler # the ::class literal, not a call
.routeInjected() --references--> Handler
.routeInjected() --calls--> .handle()
Routes.php --imports--> Handler
Routes.php --imports--> Illuminate\Support\Facades\Route
And no node exists for either dispatch key:
nodes whose label contains '.new' -> []
nodes whose label contains 'webhook' -> []
Impact on the real repo
app/Http/Controllers/StreamWebhookController.php is the sole ingress for Stream webhooks, and its whole fan-out is a match on the event type:
match ($eventType) {
'message.new' => app(HandleMessageNew::class)->handle($payload, …), // L48
'review_queue_item.new' => app(HandleReviewQueueItemNew::class)->handle($payload, …), // L49
default => null,
};
Measured on a cold-cache build at 6def3c8 (246 files, 1,946 nodes, 4,663 edges):
| Real relationship |
Edge in graph |
routes/api.php → StreamWebhookController::handle |
none (file-level imports on the class only) |
StreamWebhookController::handle L48 → HandleMessageNew::handle |
none |
StreamWebhookController::handle L49 → HandleReviewQueueItemNew::handle |
none |
event key 'message.new' |
no node |
route path '/webhooks/stream' |
no node |
Both handler classes' only inbound edge from outside their own file is StreamWebhookController.php --imports-->. graphify explain "HandleMessageNew" returns 10 connections, none of which is the invocation.
Note this repo does not hit #76's wrong-edge variant: the container call sits inside StreamWebhookController::handle() itself, so the same-named misresolution would be a self-loop and none was emitted. Here it is silently missing, which matches the documented "silent incompleteness" limit — a consumer cannot distinguish "no callers" from "callers refused".
For contrast, non-container indirection in this repo resolves correctly: ProcessLangGraphResponseJob::dispatchAfterResponse() at app/Actions/LangGraph/DispatchLangGraphAction.php:22 does produce a calls edge. The problem is specific to container calls and class-string callables.
Suggested fix
Both halves are statically recoverable because the class is a literal ::class constant at the call site:
- Class-string callables. Treat
[X::class, 'method'] in an array-callable position as a reference to X::method and emit a calls (or indirect_call) edge to the method node, not just a file-level imports to the class. This is the single change that makes Laravel route tables traversable, and it generalises to dispatch([X::class, 'm']), Bus::dispatch, and listener maps.
- Dispatch keys as nodes. When a literal string is the matched subject of a
match/switch arm (or the first argument of a Route::* call) whose body resolves to a class or method, emit a small node for that key with an edge to the resolved target. That gives the listens-to shape an argument to name — graphify affected "message.new" — which is the shape the spike graded and the only one of the four with no queryable entry point today.
Failing (1), the honest fallback is what #76 also asks for: never bind a container-call or array-callable receiver to a same-named method in the enclosing scope, and surface refusals so silent incompleteness becomes visible.
Found while validating the fork on
messaging-api.lawnstarter.comfor PE-80058 (adoption epic). This is the one failing shape of the four graded there.Version:
graphify 0.9.40, forkorigin/v8=5e9d95c11837aeabbbb5d90cb45544f49dc58ca4.Related but distinct from #76: that issue is about
app(X::class)->method()emitting a wrong edge when the enclosing class has a same-named method. This one is about the listens-to shape having no entry point at all — even a correct receiver edge would not make it answerable, because the dispatch key is never a node.Summary
The spike graded four shapes;
what listens to event X?is the one this repo fails. Two independent gaps combine to make the shape unanswerable for string-keyed dispatch tables, which is how both Laravel HTTP routing and this service's webhook fan-out are written:'message.new', nor for the route path'/webhooks/stream'. There is nothing to name in aquery/explain/affectedargument, so the question cannot be asked, let alone answered.Route::post('/path', [Controller::class, 'handle'])yields only a file-levelimportsedge. Thehandle()method has no incoming edge, soaffected "Controller.handle()"reports nothing inbound from the routing layer.Combined with the
app(X::class)refusal, the entire event-routing table of this service is invisible: an agent asking "who handlesmessage.new?" gets either nothing or an undiscriminated 310-node BFS dump.Minimal reproduction
Only the typed-parameter call is found. Dumping every edge into
Handler:And no node exists for either dispatch key:
Impact on the real repo
app/Http/Controllers/StreamWebhookController.phpis the sole ingress for Stream webhooks, and its whole fan-out is amatchon the event type:Measured on a cold-cache build at
6def3c8(246 files, 1,946 nodes, 4,663 edges):routes/api.php→StreamWebhookController::handleimportson the class only)StreamWebhookController::handleL48 →HandleMessageNew::handleStreamWebhookController::handleL49 →HandleReviewQueueItemNew::handle'message.new''/webhooks/stream'Both handler classes' only inbound edge from outside their own file is
StreamWebhookController.php --imports-->.graphify explain "HandleMessageNew"returns 10 connections, none of which is the invocation.Note this repo does not hit #76's wrong-edge variant: the container call sits inside
StreamWebhookController::handle()itself, so the same-named misresolution would be a self-loop and none was emitted. Here it is silently missing, which matches the documented "silent incompleteness" limit — a consumer cannot distinguish "no callers" from "callers refused".For contrast, non-container indirection in this repo resolves correctly:
ProcessLangGraphResponseJob::dispatchAfterResponse()atapp/Actions/LangGraph/DispatchLangGraphAction.php:22does produce acallsedge. The problem is specific to container calls and class-string callables.Suggested fix
Both halves are statically recoverable because the class is a literal
::classconstant at the call site:[X::class, 'method']in an array-callable position as a reference toX::methodand emit acalls(orindirect_call) edge to the method node, not just a file-levelimportsto the class. This is the single change that makes Laravel route tables traversable, and it generalises todispatch([X::class, 'm']),Bus::dispatch, and listener maps.match/switcharm (or the first argument of aRoute::*call) whose body resolves to a class or method, emit a small node for that key with an edge to the resolved target. That gives thelistens-toshape an argument to name —graphify affected "message.new"— which is the shape the spike graded and the only one of the four with no queryable entry point today.Failing (1), the honest fallback is what #76 also asks for: never bind a container-call or array-callable receiver to a same-named method in the enclosing scope, and surface refusals so silent incompleteness becomes visible.