{"record":{"id":"a3190f0e9f1f1472","repo":"getgrav/grav","slug":"the-callable-passed-to-the-array-group-by-filter","errorCode":null,"errorMessage":"The callable passed to the \"array_group_by\" filter must be a Closure in sandbox mode.","messagePattern":"The callable passed to the \"array_group_by\" filter must be a Closure in sandbox mode\\.","errorType":"exception","errorClass":"RuntimeError","httpStatus":null,"severity":"error","filePath":"system/src/Grav/Common/Twig/Extension/GravExtension.php","lineNumber":1395,"sourceCode":"     *\n     * A string $callback would let sandboxed content call an arbitrary\n     * method on each item by name ($item->$callback()), and any other PHP\n     * callable would let it invoke an arbitrary global function via\n     * call_user_func() — neither goes through the sandbox, so both are\n     * refused while sandboxed. Outside the sandbox both keep working exactly\n     * as before.\n     *\n     * @param bool $isSandboxed Whether the current render is sandboxed (injected by Twig)\n     * @param array|\\Traversable $array The array or collection to group\n     * @param string|callable $callback Property name or callable to determine group key.\n     *                                  Must be a \\Closure when $isSandboxed is true.\n     * @return array Grouped array with keys as group identifiers and values as arrays of items\n     * @throws RuntimeError if $callback is not a Closure while sandboxed\n     */\n    public function arrayGroupByFilter(bool $isSandboxed, $array, $callback): array\n    {\n        if ($isSandboxed && !$callback instanceof \\Closure) {\n            throw new RuntimeError('The callable passed to the \"array_group_by\" filter must be a Closure in sandbox mode.');\n        }\n\n        $groups = [];\n\n        // Convert to array if it's a Traversable object (like Grav Collections)\n        if ($array instanceof \\Traversable) {\n            $array = iterator_to_array($array);\n        }\n\n        if (!is_array($array)) {\n            return [];\n        }\n\n        foreach ($array as $key => $item) {\n            if ($callback instanceof \\Closure) {\n                // Sandboxed arrow function: attribute access inside it is\n                // already checked by Twig's own attribute compilation.\n                $groupKey = $callback($item, $key);","sourceCodeStart":1377,"sourceCodeEnd":1413,"githubUrl":"https://github.com/getgrav/grav/blob/6040efed04efa69b8209448ed81308e7c24147c2/system/src/Grav/Common/Twig/Extension/GravExtension.php#L1377-L1413","documentation":"Grav's array_group_by filter accepts either a property-name string or a callable to compute the group key. When the render is sandboxed (Twig injects $isSandboxed), only a \\Closure is accepted: a string could name an arbitrary PHP function that would then be invoked once per item. Twig arrow functions (v => v.category) compile to Closures, so idiomatic templates are unaffected; the string shorthand keeps working only in unsandboxed templates.","triggerScenarios":"In sandboxed content: {{ items|array_group_by('category') }} (string shorthand) or any non-Closure callable; passing a string that happens to name a PHP function; snippets written for unsandboxed theme templates reused inside sandboxed page content.","commonSituations":"Page markdown that groups collections via the string shorthand; templates written before the sandbox hardening; mixed theme/content code paths where one context works and the other throws.","solutions":["Use an arrow function so the callback is a Closure: {{ items|array_group_by(v => v.category) }} — works in and out of the sandbox","Do the grouping in PHP (plugin or Twig extension) and pass the grouped array to the template","Avoid array_group_by in sandboxed content if the callback shape cannot be changed"],"exampleFix":"{# before (sandboxed): string callable refused #}\n{{ items|array_group_by('category') }}\n\n{# after: arrow function compiles to a Closure #}\n{{ items|array_group_by(v => v.category) }}","handlingStrategy":"validation","validationCode":"// normalize the callback before rendering sandboxed content that uses array_group_by\nif ($isSandboxed && is_string($callback) && !$callback instanceof \\Closure) {\n    $field = $callback;\n    $callback = fn($item) => is_array($item) ? ($item[$field] ?? null) : null; // now a Closure\n}","typeGuard":"function isSandboxSafeCallback(mixed $callback, bool $isSandboxed): bool\n{\n    return !$isSandboxed || $callback instanceof \\Closure;\n}","tryCatchPattern":"use Twig\\Error\\RuntimeError;\ntry { echo $twig->render($sandboxedTemplate, $data); }\ncatch (RuntimeError $e) { log_template_error($e); echo '<!-- grouping refused in sandbox -->'; }","preventionTips":["Author array callbacks as Twig arrow functions (v => v.field) everywhere","Lint sandboxed snippets for string callables passed to array_group_by","Precompute grouped data in PHP when templates need the string shorthand"],"tags":["twig","sandbox","security","filter","callable"],"backgroundTag":"twig-sandbox-violation","analyzedSha":"6040efed04efa69b8209448ed81308e7c24147c2","analyzedAt":"2026-08-17T05:07:31.593Z","schemaVersion":2},"datasetVersion":"2026-08-17T09:17:11.063Z"}