Verified on Shopware 6.7

Shopware's Twig environment ships with a dump() function. It works. It outputs a variable inspection inline and then the page continues rendering. That last part - the page continues rendering - is exactly what makes it insufficient for real debugging.

When something goes wrong deep inside a template render cycle, you often need execution to stop the moment you see the data. Not after the template finishes. Not after a redirect fires. Not after an error handler swallows your output. Right there. The same way dd() works in PHP.

Twig doesn't have that. So you add it.


What's Wrong with dump()

Twig's native dump() has three problems in practice.

It doesn't stop execution. The template finishes rendering after the dump. If the variable you're investigating causes an error two blocks later, you still get the error page - and depending on where it happens, your dump() output might be hidden above the fold, inside a swallowed response, or entirely absent because the response was intercepted.

Symfony redirects eat it. If anything in the response chain triggers a redirect before the output is flushed to the browser, your dump disappears. Common in Shopware because controllers and subscribers can redirect on various conditions.

It sometimes ends up in the Symfony profiler bar instead of inline. Depending on the Symfony debug configuration and the point in the render cycle where dump() is called, output can be routed to the profiler toolbar rather than rendered in the page body. That's fine for some inspections, useless if you need to see the exact rendered position.

dd() sidesteps all of this. It dumps with Symfony's VarDumper, sends a 500 header to prevent caching and redirect interception, and calls exit(1). The request ends there.


The Extension

<?php declare(strict_types=1);

namespace Acme\Plugin\TwigExtensions;

use Symfony\Component\VarDumper\VarDumper;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

class DumpAndDie extends AbstractExtension
{
    public function getFunctions(): array
    {
        return [
            new TwigFunction('dd', [$this, 'dumpAndDie'], ['is_safe' => ['html']]),
        ];
    }

    public function dumpAndDie(mixed ...$vars): never
    {
        if (!\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true) && !headers_sent()) {
            header('HTTP/1.1 500 Internal Server Error');
            header('Content-Type: text/html; charset=UTF-8');
        }

        if (!$vars) {
            VarDumper::dump(null);
            exit(1);
        }

        foreach ($vars as $v) {
            VarDumper::dump($v);
        }

        exit(1);
    }
}

Several non-obvious decisions in here worth understanding.


is_safe => ['html'] - The Part That Trips Everyone Up

VarDumper::dump() produces formatted HTML output with inline styles and <pre> tags. This is what gives it the collapsible tree structure and syntax coloring in the browser.

Without is_safe => ['html'], Twig treats the output of any function call as unsafe and HTML-escapes it before rendering. Your dump would appear as raw escaped HTML in the page source - &lt;pre class=&quot;sf-dump&quot;&gt; - invisible in the browser, useless for debugging.

'is_safe' => ['html'] tells Twig that this function's return value is already safe HTML and should not be escaped. It's the same option used by Twig's own dump() extension internally. Without it, the extension registers but produces nothing readable.


mixed ...$vars - Variadic Args Mirror PHP's dd()

The variadic signature means you can pass any number of arguments, same as PHP's native dd():

{{ dd(product) }}
{{ dd(product, cart, salesChannelContext) }}
{{ dd() }}

Each argument gets its own VarDumper::dump() call. If called with no arguments, it dumps null and exits - useful as a "did execution reach this point?" marker.


The SAPI Guard on Headers

if (!\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true) && !headers_sent()) {
    header('HTTP/1.1 500 Internal Server Error');
    header('Content-Type: text/html; charset=UTF-8');
}

Two things happening here.

The PHP_SAPI check prevents header() calls when running in CLI context (console commands, scheduled tasks). If a Twig template is rendered during a CLI command and hits dd(), calling header() would throw a warning.

The 500 Internal Server Error header is intentional. It prevents HTTP caches, reverse proxies, and CDNs from caching the response. It also prevents browser-level redirect handling - a 302 with a Location header would normally be followed automatically, taking your dump output with it.


never Return Type

never is a PHP 8.1 return type that signals to the type checker that this function never returns normally - it either throws an exception or terminates execution. The type checker propagates this correctly through call sites: code after dd() is treated as unreachable, and static analysis tools won't complain about missing return statements in functions that call dd() on all branches.


Registration

<service id="Acme\Plugin\TwigExtensions\DumpAndDie">
    <tag name="twig.extension"/>
</service>

That's all. Shopware's container picks up twig.extension tagged services automatically and registers them with the Twig environment. Clear cache once after adding the service definition.


Usage

{# single variable #}
{{ dd(product) }}

{# multiple variables - each dumped in sequence #}
{{ dd(product, lineItem, context) }}

{# existence check - did we get here? #}
{{ dd() }}

{# inside a loop - dumps first iteration and stops #}
{% for item in items %}
    {{ dd(item) }}
{% endfor %}

The output is identical to what PHP's dd() produces: Symfony's collapsible VarDumper tree with type annotations, array lengths, object class names, and expandable nested structures.


One Caution

This is a development tool. The exit(1) call terminates the PHP process unconditionally, which means it will also terminate production requests if it somehow makes it into a deployed template. Keep it in a plugin that is either disabled in production or gated behind an environment check, or add a $_ENV['APP_ENV'] === 'dev' guard to dumpAndDie() before the dump calls. The function signature already uses never, so any added early return before the exit path would need to be refactored - simpler to just not deploy it to production at all.