Skip to content

PHP: closures produce no nodes in any position — assigned, arrow, or passed as an argument #3409

Closed

Description

@laurism-openelms

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

<?php
function namedFn() { return 1; }
$assigned = function () { return 2; };
$arrowAssigned = fn() => 3;
$app->get('/route-a', function ($req, $res) { return 4; });
$app->group('/g', function ($group) {
    $group->post('/route-b', function ($req, $res) { return 5; });
});
class C { public function method() { return 6; } }

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.

Environment

graphify 0.9.56, PHP 8.2 / Slim 4 codebase, macOS 15 (arm64), installed via uv tool install graphifyy.

Activity

  1. nikhilsaxena04 commented on Sep 9, 2026

    @nikhilsaxena04
    Contributor

    Hey! I've opened a PR for this.

    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!

  2. laurism-openelms commented on Sep 10, 2026

    @laurism-openelms
    Author

    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:

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

  3. nikhilsaxena04 commented on Sep 10, 2026

    @nikhilsaxena04
    Contributor

    Thanks for the feedback! I've addressed all the points:

    1. Route closures now get semantic VERB /path labels (e.g., GET /api/users) as you suggested — highly stable and queryable.
    2. 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.
    3. I added a note to the commit message highlighting that adding closure types to function_boundary_types will move existing edges.

    Let me know if it's fine!

  4. laurism-openelms commented on Sep 10, 2026

    @laurism-openelms
    Author

    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 () { return expensive(); });
    // 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) { return 1; });
    });
    
    // expect: {closure#1}, not GET /user:42
    $value = $cache->get('user:42', function () { return 2; });
  5. nikhilsaxena04 commented on Sep 10, 2026

    @nikhilsaxena04
    Contributor

    Thanks for the detailed review — both points are addressed:

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

    2. 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 composition
    • test_php_cache_get_avoids_route_false_positive — verifies the / guard

    Let me know if there's anything else!

  6. laurism-openelms commented on Sep 10, 2026

    @laurism-openelms
    Author

    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:

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

    Thanks for turning both rounds around so quickly.

  7. nikhilsaxena04 commented on Sep 10, 2026

    @nikhilsaxena04
    Contributor

    I'm loving it actually, these feedbacks are incredibly precise and really helps me write better code.

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

    Let me know when you're ready to merge!

  8. laurism-openelms commented on Sep 10, 2026

    @laurism-openelms
    Author

    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.

  9. nikhilsaxena04 commented on Sep 10, 2026

    @nikhilsaxena04
    Contributor

    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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions