{"record":{"id":"bd0b7187eefb8ecc","repo":"phalcon/cphalcon","slug":"cannot-cache-router-route-id-routeid-has-a-cl-bd0b71","errorCode":null,"errorMessage":"Cannot cache router: route id '{routeId}' has a Closure converter for '{convName}' - only string/array callables are cacheable","messagePattern":"Cannot cache router: route id '(.+?)' has a Closure converter for '(.+?)' - only string/array callables are cacheable","errorType":"exception","errorClass":"Phalcon\\Mvc\\Router\\Exception","httpStatus":null,"severity":"error","filePath":"phalcon/Mvc/Router.zep","lineNumber":739,"sourceCode":"        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\n            let dumpedRoutes[] = [\n                \"class\":       get_class(route),\n                \"pattern\":     route->getPattern(),\n                \"paths\":       route->getPaths(),\n                \"methods\":     route->getHttpMethods(),\n                \"hostname\":    route->getHostname(),\n                \"name\":        route->getName(),\n                \"id\":          route->getRouteId(),\n                \"beforeMatch\": cb,\n                \"converters\":  converters","sourceCodeStart":721,"sourceCodeEnd":757,"githubUrl":"https://github.com/phalcon/cphalcon/blob/b7419de9cd0a8a3f48441ead84c9f8415d463e25/phalcon/Mvc/Router.zep#L721-L757","documentation":"Like beforeMatch callbacks, route converters (parameter transformers registered with ->convert()) are inspected during router dispatcher cache dumps. A converter stored as a \\Closure cannot be exported by var_export(), so buildDispatcherDump()/dumpDispatcher()/useCache() aborts with this exception, naming the route id and the converter name (e.g. the 'id' converter).","triggerScenarios":"Defining ->convert('id', function ($value) { return (int) $value; }) (or any closure converter) on a route and then enabling router caching; casting/normalizing parameters inline via closures, which is the common idiom in docs; dumping the dispatcher in a deploy script.","commonSituations":"Idiomatic closure converters copied from documentation or older projects; adding useCache() to speed up route matching and discovering the first route that normalizes parameters with a closure; the exception fires at cache-build time, so it blocks deploys rather than requests.","solutions":["Move the conversion into a static method and reference it as a string callable 'App\\Converters\\IntConverter::cast' or array callable","For pure casts, let the dispatcher/controller handle the value instead of a converter, or drop the converter if the parameter needs no transformation","Keep closures only on routes excluded from the cached set - but note the dumper has no exclude flag, so realistically all converters must be non-closure","Validate routes for closure converters during CI so failures surface before deploy"],"exampleFix":"// before\n$route->convert('id', function ($value) {\n    return (int) $value; // Closure converter -> cache dump throws\n});\n\n// after\nclass Cast\n{\n    public static function toInt($value): int\n    {\n        return (int) $value;\n    }\n}\n$route->convert('id', [Cast::class, 'toInt']); // cacheable","handlingStrategy":"validation","validationCode":"// Before dumping, verify no converter is a Closure\nforeach ($router->getRoutes() as $route) {\n    foreach ($route->getConverters() ?? [] as $name => $converter) {\n        if ($converter instanceof \\Closure) {\n            throw new LogicException('Converter \"' . $name . '\" on route ' . $route->getRouteId() . ' is a Closure; use Class::method');\n        }\n    }\n}\n$router->dumpDispatcher($path);","typeGuard":"function isCacheableConverter(mixed $converter): bool\n{\n    return (is_string($converter) || is_array($converter)) && is_callable($converter);\n}","tryCatchPattern":"try {\n    $router->dumpDispatcher($path);\n} catch (\\Phalcon\\Mvc\\Router\\Exception $e) {\n    // e.g. \"Closure converter for 'id'\" - refactor that converter to a static method, re-run\n    $logger->error($e->getMessage());\n}","preventionTips":["Convert parameter normalization into small static converter classes from day one","Include route-metadata linting (no Closures anywhere) in the build that generates the cache","After refactoring any callable, regenerate the cache in the same commit"],"tags":["phalcon","router","caching","closures","converters","serialization"],"backgroundTag":"closure-not-serializable","analyzedSha":"b7419de9cd0a8a3f48441ead84c9f8415d463e25","analyzedAt":"2026-08-21T06:21:18.811Z","schemaVersion":2},"datasetVersion":"2026-08-21T11:28:35.574Z"}