Skip to content

PHP/Laravel: string-keyed dispatch is unqueryable — event keys and route paths are not nodes, and [X::class, 'method'] callables emit no method edge (PE-80058) #82

Description

@filipechagas

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:

  1. 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.
  2. 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.phpStreamWebhookController::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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions