twigphp/Twig · error · RuntimeError

The callable passed to the

Error message

The callable passed to the "%s" %s must be a Closure in sandbox mode.

What it means

CoreExtension::checkArrow validates the arrow-function argument passed to `sort`, `filter`, `find`, `map`, `reduce`, `has some`, or `has every`. In sandbox mode every callable must be a PHP \Closure, because arbitrary callables (like 'strlen' or [Object, 'method']) would let sandboxed templates invoke unvetted code. Twig throws this RuntimeError when a non-Closure callable is used under an enabled sandbox; outside the sandbox it merely triggers a deprecation (since Twig 3.15).

Solutions

  1. Ensure the arrow argument is a PHP \Closure — in templates arrow functions `v => ...` compile to closures; in custom extension code wrap callables: `$arrow = \Closure::fromCallable($callable);`.
  2. Clear the Twig cache after enabling/changing the sandbox so templates recompile with sandbox-safe arrow expressions.
  3. If the callable genuinely cannot be a Closure, expose it as a registered Twig function/filter instead of passing it as an argument.
  4. Check the twig/twig version: on >=3.15 this is a hard sandbox error; restructure code that relied on string callables in sandboxed templates.

Example fix

// before (PHP custom extension)
$twig->addFilter(new TwigFilter('custom', function ($items) {
    return twig_array_filter($items, 'my_predicate'); // string callable
}));

// after
$twig->addFilter(new TwigFilter('custom', function ($items) {
    return twig_array_filter($items, \Closure::fromCallable('my_predicate'));
}));
Defensive patterns

Strategy: validation

Validate before calling

// PHP, before passing an arrow to filter/map/reduce under a sandbox
function ensureClosureForSandbox(callable $arrow): \Closure {
    return $arrow instanceof \Closure ? $arrow : \Closure::fromCallable($arrow);
}

Type guard

function isSandboxSafeArrow($arrow): bool { return $arrow instanceof \Closure; }

Try / catch

try {
    $html = $sandboxedTwig->render('user_template.html.twig', $context);
} catch (\Twig\Error\RuntimeError $e) {
    if (str_contains($e->getMessage(), 'must be a Closure in sandbox mode')) {
        // reject/fix the template or supply a closure-based implementation
    } else { throw $e; }
}

Prevention

When it happens

Trigger: Rendering a template with `|filter`, `|map`, `|sort`, `|reduce`, `find`, `has some/every` through a sandboxed Twig\Environment (Extension\SandboxExtension enabled) while the compiled call site passes a non-Closure callable — typically because the template was compiled by a non-sandboxed parser or a custom extension supplies a plain callable/string function name as the arrow argument.

Common situations: Applications embedding user-supplied templates with the sandbox enabled; cached compiled templates reused between sandboxed and non-sandboxed environments; custom extensions passing `callable` parameters (strings like 'trim') instead of closures; upgrading Twig to >=3.15 where this check was tightened.

Understand the failure class

Background: Type mismatch errors: IllegalArgumentException, TypeError and type guards across 150 open-source libraries — this error's family across 150 libraries.

Related errors


AI-assisted analysis of twigphp/Twig@a414c3a491 (2026-09-13). Data as JSON: /api/errors/8d8c9e6c61ca108f. Report an issue: GitHub.

Appendix: source

Thrown at src/Extension/CoreExtension.php:2189

            if (!$arrow($v, $k)) {
                return false;
            }
        }

        return true;
    }

    /**
     * @internal
     */
    public static function checkArrow(bool $isSandboxed, $arrow, $thing, $type): void
    {
        if ($arrow instanceof \Closure) {
            return;
        }

        if ($isSandboxed) {
            throw new RuntimeError(\sprintf('The callable passed to the "%s" %s must be a Closure in sandbox mode.', $thing, $type));
        }

        trigger_deprecation('twig/twig', '3.15', 'Passing a callable that is not a PHP \Closure as an argument to the "%s" %s is deprecated.', $thing, $type);
    }

    /**
     * @internal to be removed in Twig 4
     */
    public static function captureOutput(iterable $body): string
    {
        $level = ob_get_level();
        ob_start();

        try {
            foreach ($body as $data) {
                echo $data;
            }
        } catch (\Throwable $e) {

View on GitHub (pinned to a414c3a491)