You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
PHP: closures produce no nodes in any position — assigned, arrow, or passed as an argument #3409
Title: PHP: closures produce no nodes in any position — assigned, arrow, or passed as an argument
Summary
The PHP extractor nodes named functions and class methods, but no closure form produces a node. This differs from JS/TS, which nodes const f = function(){}. The practical effect is that closure-based routing layers (Slim, Laravel) are absent from the graph.
Repro
<?phpfunctionnamedFn() { return1; }
$assigned = function () { return2; };
$arrowAssigned = fn() => 3;
$app->get('/route-a', function ($req, $res) { return4; });
$app->group('/g', function ($group) {
$group->post('/route-b', function ($req, $res) { return5; });
});
class C { publicfunctionmethod() { return6; } }
graphify update . produces exactly:
.method()
C
namedFn()
p.php
Missing:$assigned, $arrowAssigned, and both route handlers.
Observed vs expected
Observed: no node for any closure, so nothing connects a route to the services its handler calls.
Expected: at minimum, a node for a closure assigned to a variable, matching the JS const f = function(){} behaviour. For a closure passed as a call argument, a node contained by the file, ideally labelled from the call, would make route handlers addressable.
The constructs are anonymous_function_creation_expression and arrow_function, in both assignment and argument positions.
Impact
Measured on one private Slim 4 codebase, 0.9.56, fully rebuilt:
layer
files
nodes
avg/file
contributing only the file node
PHP class layer
844
7913
9.4
1 (0%)
Route files (closure handlers)
219
240
1.1
207 (95%)
One route file with 17 registrations and 0 named functions yields 1 node. Classes extract completely, so no query can traverse from an HTTP endpoint into the service layer behind it.
Related
The JS counterpart of the argument-position case is filed separately. No existing PHP issue covers closures — #1682 and #2615/#2617 concern member-call resolution, #3024 concerned interfaces/traits/enums.
I've added anonymous_function_creation_expression and arrow_function to the PHP extractor's recognized function types. To prevent ID collisions, they're dynamically named {closure@<line>} when extracted. API routes and backend logic are now connected in the graph!
Thanks for picking this up — the extraction side looks right.
One question on {closure@<line>}. Node ids appear to derive from labels, so a line number in the label puts a line number in the id. That means any edit above a closure renames every closure below it, and an incremental update evicts and recreates those nodes rather than matching them. Saved community labels and anything citing a closure id would go stale on an unrelated edit. The test hard-coding {closure@2} / {closure@5} shows the coupling.
Two alternatives that keep ids stable across edits:
Ordinal within the enclosing scope — {closure#1}, {closure#2} within a file or function. Stable unless closures are added or reordered, which is a much rarer edit than adding a line above one.
Derive from the call it is passed to — for the routing case this is both stable and more useful. $app->get('/api', function () {}) becomes something like GET /api, which is what someone querying the graph would actually search for. This is the case the issue was filed about, so it may be worth special-casing the argument position even if unnamed closures elsewhere keep a generic name.
Separately, adding the closure types to function_boundary_types means calls inside a closure now attribute to the closure instead of the enclosing named function. That is probably the correct behaviour, but it moves existing edges on any codebase that already has closures inside named functions, so it may deserve a changelog note.
Thanks for the feedback! I've addressed all the points:
Route closures now get semantic VERB /path labels (e.g., GET /api/users) as you suggested — highly stable and queryable.
All other generic closures now use an ordinal {closure#1}, {closure#2} scoped per file/class, so line insertions above them no longer break the IDs. Only reordering closures shifts later ordinals.
I added a note to the commit message highlighting that adding closure types to function_boundary_types will move existing edges.
This is a big improvement. The VERB /path labels are exactly right for the flat case, and the ordinal scope solves the churn problem.
Two things the current lookup misses. Both come from reading only the immediate call's argument_list.
1. Grouped routes lose their prefix. Most real Slim and Laravel routes are nested:
$app->group('/api/v1', function (RouteCollectorProxy$group) {
$group->get('/users/{id}', function ($req, $res) { ... });
});
The walk stops at arg_list.parent, so this labels as GET /users/{id} rather than GET /api/v1/users/{id}. Two groups in the same file that each define get('/{id}') then collide on one label. Route-named closures skip the ordinal counter, so nothing separates them. Composing the prefix means walking up through enclosing group() or prefix() calls and joining their string arguments.
2. get matches non-routing calls. The verb set keys on the method name alone, so a cache or config accessor with a default callback picks up a route label:
$value = $cache->get('user:42', function () { returnexpensive(); });
// currently labels as: GET /user:42
A cheap guard is to require the path argument to start with /. Routing paths do, cache keys generally do not. That also skips $collection->map(...) and similar.
Two test cases would cover both:
// expect: GET /api/v1/users/{id}$app->group('/api/v1', function ($group) {
$group->get('/users/{id}', function ($req, $res) { return1; });
});
// expect: {closure#1}, not GET /user:42$value = $cache->get('user:42', function () { return2; });
Thanks for the detailed review — both points are addressed:
Nested route prefixes: Replaced the single-level lookup with a recursive AST traversal helper (_php_get_route_name) that walks up across closure boundaries, collecting each enclosing group() / prefix() path segment and composing them in order. $app->group('/api/v1', fn{ $group->get('/users/{id}', fn) }) now correctly produces GET /api/v1/users/{id}.
False positives on non-routing get(): The helper now requires the path argument to begin with / before treating the call as a route. $cache->get('user:42', fn) fails that check and falls back to the ordinal {closure#1}.
Both cases from your comment are covered by the new test suite:
Traced the new helper against the nested case and it composes correctly. The walk crosses the closure boundary, picks up the group segment, and reverses in the right order to give GET /api/v1/users/{id}.
The guard placement looks right too. Requiring the innermost call to be a routing verb with a leading slash makes the common false positives fall out on their own, since array_map and usort have no string argument before the closure. Root paths behave as well, with group('/api') plus get('/') giving GET /api rather than a trailing slash.
Nothing blocking from my side. Two optional nits:
Two identical routes in the same file still share a label, because route-named closures bypass the ordinal counter. Unlikely in practice now that prefixes compose, so probably not worth handling.
_routing_verbs and _php_get_route_name are rebuilt per file inside _extract_generic. Hoisting the frozenset to module level would save a little work on large trees.
I'm loving it actually, these feedbacks are incredibly precise and really helps me write better code.
I completely agree with your first point. It's an unlikely edge case in practice now that prefixes compose, so I left it as-is to keep the logic simple.
That's a great call on the re-initialization overhead! I've just pushed a commit hoisting both the _PHP_ROUTING_VERBS frozenset and the _php_get_route_name helper out of _extract_generic up to the module level.
Hoist looks right, and keeping php_closure_counts inside _extract_generic is the correct call, since it has to reset per extraction run.
Nothing further from me. Worth flagging though that I only reported the issue, so the merge is not mine to make. @safishamsi owns that. Happy to re-test on my codebase once it lands in a release.
@safishamsi The original reporter has reviewed the extraction logic and confirmed it addresses the issues with Laravel/Slim routing closures. It's ready for your final review whenever you have time!
Activity
nikhilsaxena04 commentedon Sep 9, 2026
Hey! I've opened a PR for this.
I've added
anonymous_function_creation_expressionandarrow_functionto the PHP extractor's recognized function types. To prevent ID collisions, they're dynamically named{closure@<line>}when extracted. API routes and backend logic are now connected in the graph!laurism-openelms commentedon Sep 10, 2026
Thanks for picking this up — the extraction side looks right.
One question on
{closure@<line>}. Node ids appear to derive from labels, so a line number in the label puts a line number in the id. That means any edit above a closure renames every closure below it, and an incrementalupdateevicts and recreates those nodes rather than matching them. Saved community labels and anything citing a closure id would go stale on an unrelated edit. The test hard-coding{closure@2}/{closure@5}shows the coupling.Two alternatives that keep ids stable across edits:
{closure#1},{closure#2}within a file or function. Stable unless closures are added or reordered, which is a much rarer edit than adding a line above one.$app->get('/api', function () {})becomes something likeGET /api, which is what someone querying the graph would actually search for. This is the case the issue was filed about, so it may be worth special-casing the argument position even if unnamed closures elsewhere keep a generic name.Separately, adding the closure types to
function_boundary_typesmeans calls inside a closure now attribute to the closure instead of the enclosing named function. That is probably the correct behaviour, but it moves existing edges on any codebase that already has closures inside named functions, so it may deserve a changelog note.fix(extract): stable closure naming for PHP (Graphify-Labs#3409)
nikhilsaxena04 commentedon Sep 10, 2026
Thanks for the feedback! I've addressed all the points:
VERB /pathlabels (e.g.,GET /api/users) as you suggested — highly stable and queryable.{closure#1},{closure#2}scoped per file/class, so line insertions above them no longer break the IDs. Only reordering closures shifts later ordinals.function_boundary_typeswill move existing edges.Let me know if it's fine!
laurism-openelms commentedon Sep 10, 2026
This is a big improvement. The
VERB /pathlabels are exactly right for the flat case, and the ordinal scope solves the churn problem.Two things the current lookup misses. Both come from reading only the immediate call's
argument_list.1. Grouped routes lose their prefix. Most real Slim and Laravel routes are nested:
The walk stops at
arg_list.parent, so this labels asGET /users/{id}rather thanGET /api/v1/users/{id}. Two groups in the same file that each defineget('/{id}')then collide on one label. Route-named closures skip the ordinal counter, so nothing separates them. Composing the prefix means walking up through enclosinggroup()orprefix()calls and joining their string arguments.2.
getmatches non-routing calls. The verb set keys on the method name alone, so a cache or config accessor with a default callback picks up a route label:A cheap guard is to require the path argument to start with
/. Routing paths do, cache keys generally do not. That also skips$collection->map(...)and similar.Two test cases would cover both:
fix(extract): robust PHP nested routing closure extraction (Graphify-…
nikhilsaxena04 commentedon Sep 10, 2026
Thanks for the detailed review — both points are addressed:
Nested route prefixes: Replaced the single-level lookup with a recursive AST traversal helper (
_php_get_route_name) that walks up across closure boundaries, collecting each enclosinggroup()/prefix()path segment and composing them in order.$app->group('/api/v1', fn{ $group->get('/users/{id}', fn) })now correctly producesGET /api/v1/users/{id}.False positives on non-routing
get(): The helper now requires the path argument to begin with/before treating the call as a route.$cache->get('user:42', fn)fails that check and falls back to the ordinal{closure#1}.Both cases from your comment are covered by the new test suite:
test_php_nested_route_closure_composes_prefix— verifies prefix compositiontest_php_cache_get_avoids_route_false_positive— verifies the/guardLet me know if there's anything else!
laurism-openelms commentedon Sep 10, 2026
Traced the new helper against the nested case and it composes correctly. The walk crosses the closure boundary, picks up the group segment, and reverses in the right order to give
GET /api/v1/users/{id}.The guard placement looks right too. Requiring the innermost call to be a routing verb with a leading slash makes the common false positives fall out on their own, since
array_mapandusorthave no string argument before the closure. Root paths behave as well, withgroup('/api')plusget('/')givingGET /apirather than a trailing slash.Nothing blocking from my side. Two optional nits:
_routing_verbsand_php_get_route_nameare rebuilt per file inside_extract_generic. Hoisting the frozenset to module level would save a little work on large trees.Thanks for turning both rounds around so quickly.
refactor(extract): hoist PHP route helper to module level (Graphify-L…
nikhilsaxena04 commentedon Sep 10, 2026
I'm loving it actually, these feedbacks are incredibly precise and really helps me write better code.
_PHP_ROUTING_VERBSfrozenset and the_php_get_route_namehelper out of_extract_genericup to the module level.Let me know when you're ready to merge!
laurism-openelms commentedon Sep 10, 2026
Hoist looks right, and keeping
php_closure_countsinside_extract_genericis the correct call, since it has to reset per extraction run.Nothing further from me. Worth flagging though that I only reported the issue, so the merge is not mine to make. @safishamsi owns that. Happy to re-test on my codebase once it lands in a release.
nikhilsaxena04 commentedon Sep 10, 2026
Thanks @laurism-openelms!
@safishamsi The original reporter has reviewed the extraction logic and confirmed it addresses the issues with Laravel/Slim routing closures. It's ready for your final review whenever you have time!
feat(extract): support PHP closures (#3409)
fix(extract): stable closure naming for PHP (#3409)
fix(extract): robust PHP nested routing closure extraction (#3409)
refactor(extract): hoist PHP route helper to module level (#3409)
test(php): cover file-scope, no-duplicate, and method-scope closures (G…