Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,40 @@ jobs:
- run: bash ./tests/drupal_test.sh
shell: "bash"

xdebug-tests:
needs:
- 'tests'

name: "XDebug source map tests"

runs-on: 'ubuntu-latest'

continue-on-error: true

steps:
- name: "Checkout code"
uses: actions/checkout@v4

- name: "Install PHP with XDebug"
uses: shivammathur/setup-php@v2
with:
coverage: "xdebug"
php-version: '8.4'
ini-values: memory_limit=-1, xdebug.mode=debug

- name: "Check XDebug version and functions"
run: |
php -v
php -r "echo 'xdebug_set_source_map exists: '.(function_exists('xdebug_set_source_map') ? 'yes' : 'no').PHP_EOL;"

- run: composer install

- name: "Install PHPUnit"
run: vendor/bin/simple-phpunit install

- name: "Run XDebug source map tests"
run: vendor/bin/simple-phpunit tests/Cache/XdebugSourceMapCacheTest.php tests/EnvironmentTest.php --filter "Xdebug"

phpstan:
name: "PHPStan"

Expand Down
4 changes: 2 additions & 2 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 3.22.2 (2026-XX-XX)
# 3.23.0 (2026-XX-XX)

* n/a
* Add support for Xdebug path mapping

# 3.22.2 (2025-12-14)

Expand Down
12 changes: 11 additions & 1 deletion doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,20 @@ The following options are available:

``false`` (default): allows templates to use a mix of ``yield`` and ``echo``
calls to allow for a progressive migration.

Switch to ``true`` when possible as this will be the only supported mode in
Twig 4.0.

* ``xdebug_source_map`` *boolean*

Enables generation of Xdebug source map files for debugging Twig templates.
Xdebug 3.5+ can use these maps to set breakpoints directly in ``.twig`` files.

Defaults to the value of ``debug``. Set to ``false`` to disable even when
debugging is enabled. Requires a filesystem-based cache.

See :ref:`debugging-templates` for setup instructions.

Loaders
-------

Expand Down
6 changes: 3 additions & 3 deletions doc/functions/dump.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ read:

.. tip::

Using a ``pre`` tag is not needed when `XDebug`_ is enabled and
Using a ``pre`` tag is not needed when `Xdebug`_ is enabled and
``html_errors`` is ``on``; as a bonus, the output is also nicer with
XDebug enabled.
Xdebug enabled.

You can debug several variables by passing them as additional arguments:

Expand All @@ -62,5 +62,5 @@ Arguments

* ``context``: The context to dump

.. _`XDebug`: https://xdebug.org/docs/display
.. _`Xdebug`: https://xdebug.org/docs/display
.. _`var_dump`: https://www.php.net/var_dump
57 changes: 57 additions & 0 deletions doc/recipes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -553,4 +553,61 @@ safe to avoid any escaping. You can do so by wrapping your expression with a

$safeExpr = new RawFilter(new YourSafeNode());

.. _debugging-templates:

Debugging Twig Templates with Xdebug
------------------------------------

Xdebug 3.5+ supports native path mapping, which allows setting breakpoints
directly in ``.twig`` files and having Xdebug map them to the correct lines
in the compiled PHP files.

When ``debug`` is enabled and a filesystem-based cache is configured, Twig
automatically generates Xdebug source map files in the ``.xdebug`` subdirectory
of the cache directory::

$twig = new \Twig\Environment($loader, [
'debug' => true,
'cache' => '/path/to/cache',
]);

To disable source map generation while keeping debug enabled, set
``xdebug_source_map`` to ``false``.

Configure Xdebug in ``php.ini``::

xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.path_mapping=1

You can then set breakpoints in ``.twig`` files. When debugging, template
variables are available in the ``$context`` array (e.g., ``$context['name']``).

Twig automatically registers source map files with Xdebug.

VSCode-based IDE Setup
~~~~~~~~~~~~~~~~~~~~~~

1. Enable breakpoints in Twig files by adding to ``.vscode/settings.json``:

.. code-block:: json

{
"debug.allowBreakpointsEverywhere": true
}

2. Create a ``.vscode/launch.json`` with a basic configuration in your project:

.. code-block:: json

{
"version": "0.2.0",
"configurations": [{
"name": "Listen for Xdebug",
"type": "php",
"request": "launch",
"port": 9003
}]
}

.. _callback: https://www.php.net/manual/en/function.is-callable.php
14 changes: 13 additions & 1 deletion src/Cache/ChainCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*
* @author Quentin Devos <quentin@devos.pm>
*/
final class ChainCache implements CacheInterface, RemovableCacheInterface
final class ChainCache implements CacheInterface, DirectoryCacheInterface, RemovableCacheInterface
{
/**
* @param iterable<CacheInterface> $caches The ordered list of caches used to store and fetch cached items
Expand Down Expand Up @@ -78,6 +78,18 @@ public function remove(string $name, string $cls): void
}
}

public function getDirectories(): array
{
$directories = [];
foreach ($this->caches as $cache) {
if ($cache instanceof DirectoryCacheInterface) {
$directories[] = $cache->getDirectories();
}
}

return array_merge(...$directories);
}

/**
* @return string[]
*/
Expand Down
25 changes: 25 additions & 0 deletions src/Cache/DirectoryCacheInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Twig\Cache;

/**
* Interface for caches that store files in directories.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
interface DirectoryCacheInterface
{
/**
* @return string[]
*/
public function getDirectories(): array;
}
7 changes: 6 additions & 1 deletion src/Cache/FilesystemCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*
* @author Andrew Tch <andrew@noop.lv>
*/
class FilesystemCache implements CacheInterface, RemovableCacheInterface
class FilesystemCache implements CacheInterface, DirectoryCacheInterface, RemovableCacheInterface
{
public const FORCE_BYTECODE_INVALIDATION = 1;

Expand All @@ -29,6 +29,11 @@ public function __construct(string $directory, int $options = 0)
$this->options = $options;
}

public function getDirectories(): array
{
return [$this->directory];
}

public function generateKey(string $name, string $className): string
{
$hash = hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $className);
Expand Down
161 changes: 161 additions & 0 deletions src/Cache/XdebugSourceMapCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Twig\Cache;

/**
* Cache decorator that writes Xdebug source map files alongside compiled templates.
*
* Xdebug 3.5+ supports native path mapping via .map files in .xdebug directories.
* This allows setting breakpoints in Twig templates and having Xdebug map them
* to the correct lines in the compiled PHP files.
*
* @author Fabien Potencier <fabien@symfony.com>
*
* @internal
*/
final class XdebugSourceMapCache implements CacheInterface, RemovableCacheInterface
{
private ?string $pendingTemplatePath = null;
private ?array $pendingDebugInfo = null;

public function __construct(
private DirectoryCacheInterface&CacheInterface $cache,
) {
$this->registerExistingSourceMaps();
}

/**
* Registers all existing source map files with Xdebug.
*
* This is called at startup to ensure previously compiled templates
* have their source maps available for debugging.
*/
private function registerExistingSourceMaps(): void
{
foreach ($this->cache->getDirectories() as $directory) {
$mapPath = $directory.'/.xdebug';
if (!is_dir($mapPath)) {
continue;
}

foreach (glob($mapPath.'/*.map') as $mapFile) {
xdebug_set_source_map($mapFile);

Check failure on line 51 in src/Cache/XdebugSourceMapCache.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.4)

Function xdebug_set_source_map not found.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is not available yet but this is the cleaneast way to do it IMHO.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any public discussion on the Xdebug side about this function ?

Btw, the verb set in the name is confusing if it registers a new source map without removing the previous one, as that's not the usual meaning of set in function names.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is a feature that I haven't added yet, I am more than happy to discuss how it should work. I agree that using xdebug_add_source_map is probably a better choice. Right now, the internals will reject a new source map if it touches the same files though, and that seems not so easy to change.

Alternatively, I could add a new function xdebug_add_source_map_directory, which you would only have to call once before the compiled template files are loaded into PHP's memory through include.

Then Xdebug can (additionally) scan this directory to pick up any source maps that exist.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

xdebug_add_source_map_directory might be better for the Twig use case indeed (and might maybe have less overhead for requests not using breakpoints by skipping the filesystem scanning, depending on when xdebug actually traverses those directories)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It currently scans the files (if path mapping is enabled) at the start of every request. Although for now, it only works with the step debugger, in the future I want to extend the support to maps to other features too: https://xdebug.org/funding/001-native-path-mapping#future-scope

}
}
}

public function setSourceMapData(string $templatePath, array $debugInfo): void
{
$this->pendingTemplatePath = $templatePath;
$this->pendingDebugInfo = $debugInfo;
}

public function generateKey(string $name, string $className): string
{
return $this->cache->generateKey($name, $className);
}

public function write(string $key, string $content): void
{
$this->cache->write($key, $content);

if ($this->pendingTemplatePath && $this->pendingDebugInfo) {
try {
$this->writeSourceMap($key, $this->pendingTemplatePath, $this->pendingDebugInfo);
} finally {
$this->pendingTemplatePath = null;
$this->pendingDebugInfo = null;
}
}
}

public function load(string $key): void
{
$this->cache->load($key);
}

public function getTimestamp(string $key): int
{
return $this->cache->getTimestamp($key);
}

public function remove(string $name, string $className): void
{
if ($this->cache instanceof RemovableCacheInterface) {
$this->cache->remove($name, $className);
}

$this->removeSourceMap($this->cache->generateKey($name, $className));
}

private function writeSourceMap(string $cacheKey, string $templatePath, array $debugInfo): void
{
$content = $this->buildMapContent($cacheKey, $templatePath, $debugInfo);
$filename = pathinfo($cacheKey, \PATHINFO_FILENAME).'.map';

foreach ($this->cache->getDirectories() as $directory) {
$mapPath = $directory.'/.xdebug';

if (!is_dir($mapPath)) {
mkdir($mapPath, 0777, true);
}

$mapFile = $mapPath.'/'.$filename;
$tmpFile = tempnam($mapPath, 'map');
if (false !== @file_put_contents($tmpFile, $content) && @rename($tmpFile, $mapFile)) {
@chmod($mapFile, 0666 & ~umask());
xdebug_set_source_map($mapFile);

Check failure on line 116 in src/Cache/XdebugSourceMapCache.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.4)

Function xdebug_set_source_map not found.

continue;
}

throw new \RuntimeException(\sprintf('Failed to write Xdebug source map file "%s".', $mapFile));
}
}

private function removeSourceMap(string $cacheKey): void
{
$filename = pathinfo($cacheKey, \PATHINFO_FILENAME).'.map';

foreach ($this->cache->getDirectories() as $directory) {
$mapFile = $directory.'/.xdebug/'.$filename;
if (is_file($mapFile)) {
@unlink($mapFile);
}
}
}

private function buildMapContent(string $cacheKey, string $templatePath, array $debugInfo): string
{
$lines = "# Xdebug source map for Twig template\n";
$lines .= \sprintf("remote_prefix: %s/\n", \dirname($cacheKey));
$lines .= \sprintf("local_prefix: %s/\n\n", \dirname($templatePath));

$compiledLines = array_keys($debugInfo);
for ($i = 0, $count = \count($compiledLines); $i < $count; ++$i) {
$startLine = $compiledLines[$i];
// End line is exclusive, so use next start line minus 1
// Use a reasonable max for last range (Xdebug can't handle PHP_INT_MAX)
$endLine = isset($compiledLines[$i + 1]) ? $compiledLines[$i + 1] - 1 : 999999;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like it that you need to use a random largish constant (999999) here. Perhaps it would make sense if I add an EOF marker to the parser, so that you can do:

compiled_file.php:5-EOF = orig_file.twig:4

Would that be helpful?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would definitely be helpful here.

$lines .= \sprintf(
"%s:%d-%d = %s:%d\n",
basename($cacheKey),
$startLine,
$endLine,
basename($templatePath),
$debugInfo[$startLine],
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine, this gets large, as there is a separate entry in the source file for each line.

I am planning on optimising this, using how JavaScript source maps approach the line-to-line mappings, if possible.

I would also recommend that if $startLine and $endline are the same, you should not include the $endLine in the mapping. It saves some processing on the Xdebug side (runs many times), where doing the test here needs to be done once per map-write.

}

return $lines;
}
}
Loading
Loading