{"record":{"id":"ce8f3fa011f26795","repo":"phalcon/cphalcon","slug":"cannot-cache-router-route-id-routeid-has-a-cl","errorCode":null,"errorMessage":"Cannot cache router: route id '{routeId}' has a Closure beforeMatch - only string/array callables are cacheable","messagePattern":"Cannot cache router: route id '(.+?)' has a Closure beforeMatch - only string/array callables are cacheable","errorType":"exception","errorClass":"Phalcon\\Mvc\\Router\\Exception","httpStatus":null,"severity":"error","filePath":"phalcon/Mvc/Router.zep","lineNumber":729,"sourceCode":"    {\n        var route, cb, converters, convName, converter, dumpedRoutes,\n            routeToIdx, scalarIdx, scalarSubKey, scalarVal,\n            methodRoutesScalar, candidatesScalar, staticScalar,\n            innerKey, innerVal, mostInnerVal, mostInnerArr;\n\n        if this->methodRoutesDirty {\n            this->rebuildMethodIndex();\n        }\n\n        let dumpedRoutes = [];\n        let routeToIdx   = [];\n\n        for scalarIdx, route in this->routes {\n            let routeToIdx[spl_object_id(route)] = scalarIdx;\n\n            let cb = route->getBeforeMatch();\n            if cb !== null && cb instanceof \\Closure {\n                throw new Exception(\n                    \"Cannot cache router: route id '\" . route->getRouteId() .\n                    \"' has a Closure beforeMatch - only string/array callables are cacheable\"\n                );\n            }\n\n            let converters = route->getConverters();\n            if typeof converters === \"array\" {\n                for convName, converter in converters {\n                    if converter instanceof \\Closure {\n                        throw new Exception(\n                            \"Cannot cache router: route id '\" . route->getRouteId() .\n                            \"' has a Closure converter for '\" . convName .\n                            \"' - only string/array callables are cacheable\"\n                        );\n                    }\n                }\n            }\n","sourceCodeStart":711,"sourceCodeEnd":747,"githubUrl":"https://github.com/phalcon/cphalcon/blob/b7419de9cd0a8a3f48441ead84c9f8415d463e25/phalcon/Mvc/Router.zep#L711-L747","documentation":"Router dispatcher caching (buildDispatcherDump / dumpDispatcher / useCache) serializes routes with var_export(), which cannot represent closures. Before dumping, every route's beforeMatch callback is inspected; if it is a \\Closure, the dump is aborted with this exception naming the offending route id. Only string callables ('Class::method') or array callables (['Class', 'method']) survive serialization and can be cached.","triggerScenarios":"Defining a route with ->beforeMatch(function ($uri, $route) {...}) (or fn() => ...) and then calling dumpDispatcher(), loadDispatcher-from-cache flows, or useCache($cacheAdapter); enabling router caching on an app whose routes were written with inline closure guards.","commonSituations":"Adding router caching to a previously-uncached production app and hitting the first closure guard (auth checks, maintenance-mode filters, A/B gate callbacks); generating the cache during a build step that fails only when certain dev routes are registered.","solutions":["Replace the closure with a static string callable: ->beforeMatch('App\\Filters\\MaintenanceFilter::check') or an array callable ['App\\Filters\\MaintenanceFilter', 'check']","If the guard logic cannot live in a class, remove beforeMatch from that route and enforce the condition in the controller or middleware instead","Skip router caching entirely (do not call dumpDispatcher/useCache) if closures must stay","Run the dump in CI/deploy so closure routes fail the build with the route id, not production traffic"],"exampleFix":"// before\n$router->add('/admin/:controller', ['controller' => 1])\n       ->beforeMatch(function ($uri, $route) {\n            return Auth::isAdmin(); // Closure -> cache dump throws\n       });\n\n// after\nclass AdminGate\n{\n    public static function check(string $uri, RouteInterface $route): bool\n    {\n        return Auth::isAdmin();\n    }\n}\n$router->add('/admin/:controller', ['controller' => 1])\n       ->beforeMatch([AdminGate::class, 'check']); // string/array callables are cacheable","handlingStrategy":"validation","validationCode":"// Before enabling router caching, scan for closure beforeMatch callbacks\nforeach ($router->getRoutes() as $route) {\n    $cb = $route->getBeforeMatch();\n    if ($cb instanceof \\Closure) {\n        throw new LogicException('Route ' . $route->getRouteId() . ' has a Closure beforeMatch; use a string/array callable to cache');\n    }\n}\n$router->dumpDispatcher($path); // safe now","typeGuard":"function isCacheableBeforeMatch(mixed $cb): bool\n{\n    return $cb === null || (is_string($cb) && is_callable($cb)) || (is_array($cb) && is_callable($cb));\n}","tryCatchPattern":"try {\n    $router->dumpDispatcher($path);\n} catch (\\Phalcon\\Mvc\\Router\\Exception $e) {\n    // message names the offending route id - convert its callback and re-dump\n    $router->useCache-off; // skip caching this build\n    log($e->getMessage());\n}","preventionTips":["Ban closures in beforeMatch via a coding standard once router caching is enabled","Run cache generation in CI so violations block merge with the route id in the log","Keep an inventory of route callbacks as class::method references"],"tags":["phalcon","router","caching","closures","serialization"],"backgroundTag":"closure-not-serializable","analyzedSha":"b7419de9cd0a8a3f48441ead84c9f8415d463e25","analyzedAt":"2026-08-21T06:21:18.811Z","schemaVersion":2},"datasetVersion":"2026-08-21T11:28:35.574Z"}