Fabien Potencier has detailed how the Twig sandbox works in Twig 4.0, and it's no longer an extension you flip on and off. Twig 4.0 ships a dedicated Sandbox class that owns an environment built for untrusted templates, so the templates your users write never touch the environment your application renders with.

If you let people write templates, newsletter bodies, CMS blocks, or email layouts stored in a database, this is the part of Twig you care about.

The Old Model

Before 4.0, sandboxing was a mode you toggled on a shared environment:

use Twig\Extension\SandboxExtension;

$sandboxExtension = $twig->getExtension(SandboxExtension::class);
$sandboxExtension->enableSandbox();

try {
    echo $twig->render('newsletter.twig', $context);
} finally {
    $sandboxExtension->disableSandbox();
}

Untrusted templates ran inside your application's environment. They could reach every registered global, including the app variable, and they inherited every extension, filter, function, and test you had registered. The security policy was doing all the work of walling off an environment that was never designed for untrusted code.

An Environment of Their Own

In Twig 4.0 you build a separate environment and hand it to a Sandbox:

use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Sandbox\Sandbox;
use Twig\Sandbox\SecurityPolicy;

$sandboxEnvironment = new Environment(
    new ArrayLoader($newsletterTemplates),
    ['cache' => '/var/cache/newsletters'],
);

$policy = new SecurityPolicy(
    allowedTags: ['if', 'for'],
    allowedFilters: ['escape', 'upper', 'date'],
);
$policy->setStrict(true);

$sandbox = new Sandbox($sandboxEnvironment, $policy);

echo $sandbox->render('newsletter.twig', ['name' => 'Fabien']);

// or render a template held as a string, straight from your database
echo $sandbox->createTemplate($newsletter->getBody())->render([
    'name' => 'Fabien',
]);

The environment has to be a fresh one. Passing an environment that's already been used throws a LogicException, and you should never pass your application environment, because the sandbox takes ownership of it. There's no mode to toggle either. Everything you render through Sandbox is sandboxed, including render(), display(), stream(), renderBlock(), displayBlock(), streamBlock(), and any template returned by createTemplate().

Strict by Default

SecurityPolicy in Twig 4.0 drops the historical exceptions that implicitly allowed some tags, functions, and tests. Anything not on an allow-list is denied, apart from built-ins marked as always safe, and the errors name what was blocked:

Filter "json_encode" is not allowed in "newsletter.twig" at line 1.
Calling "delete" method on a "Customer" object is not allowed in "profile.twig" at line 1.

One boundary worth reading twice: the sandbox restricts what template source can do, but PHP code invoked by an allowed filter, function, or extension still runs with full PHP capabilities. Only register callables you trust.

Untrusted Fragments Inside Trusted Pages

For the CMS case, where a user-authored block sits inside a page you control, Twig 4.0 adds a bridge. You register the extension and runtime on your trusted environment:

use Twig\Extension\SandboxBridgeExtension;
use Twig\Runtime\SandboxBridgeRuntime;
use Twig\RuntimeLoader\FactoryRuntimeLoader;

$twig->addExtension(new SandboxBridgeExtension());
$twig->addRuntimeLoader(new FactoryRuntimeLoader([
    SandboxBridgeRuntime::class => fn () => new SandboxBridgeRuntime(
        $sandbox,
    ),
]));

Then call render_sandboxed() from the trusted template:

{# page.html.twig, a trusted template rendered by your application #}
<article>
    {{ render_sandboxed(
        'block-' ~ block.id,
        {title: page.title},
        'html',
    ) }}
</article>

You map the context explicitly, so variables from the trusted template aren't copied in behind your back. The third argument declares the escaping strategy, which must be a non-empty literal string other than all. Unlike |raw, the result is only treated as safe in the context you declared.

The Upgrade Path

Twig 3.29 deprecates the sandboxed argument of include() along with the legacy SandboxExtension methods, and it already ships the Sandbox class and the render_sandboxed() function. That means you can move over on 3.29 today, and a setup that no longer uses the legacy APIs is ready for 4.0. The one wrinkle is setStrict(true), which you need on 3.29 for compatibility and which becomes a no-op on 4.0.

For the full write-up, see New in Twig 4.0: A First-Class Sandbox on the Symfony blog, and read up on the Twig 4.0 macro changes while you're planning the upgrade.