diff --git a/composer.json b/composer.json
index 14126d797f2d1eb77401245ae4212f4ff14fa6a2..7bd71f2ef3c9e48849797c23e4aaf0bab661acd5 100644
--- a/composer.json
+++ b/composer.json
@@ -52,7 +52,6 @@
         "phpmailer/phpmailer": "^6.1",
         "simplesamlphp/assert": "^0.2.7",
         "simplesamlphp/saml2": "^4.2.3",
-        "simplesamlphp/twig-configurable-i18n": "^2.3",
         "symfony/cache": "^5.0",
         "symfony/config": "^5.0",
         "symfony/console": "^5.0",
@@ -62,10 +61,11 @@
         "symfony/http-foundation": "^5.0",
         "symfony/http-kernel": "^5.0",
         "symfony/routing": "^5.0",
+        "symfony/twig-bridge": "^5.3",
         "symfony/var-exporter": "^5.0",
         "symfony/yaml": "^5.0",
-        "twig/twig": "~2.0",
-        "twig/intl-extra": "^3.3"
+        "twig/intl-extra": "^3.3",
+        "twig/twig": "^3.0"
     },
     "require-dev": {
         "ext-curl": "*",
diff --git a/composer.lock b/composer.lock
index b134a10599839f69da3bd0815eee564d6c580708..8f7f350ab6c39f97b91659a5c097838e38732277 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "0c38c49fd3cfcb907c48e95defee76dd",
+    "content-hash": "ee5222307d77291802fab2d7bf9ff285",
     "packages": [
         {
             "name": "gettext/gettext",
@@ -590,62 +590,6 @@
             },
             "time": "2021-08-24T12:21:57+00:00"
         },
-        {
-            "name": "simplesamlphp/twig-configurable-i18n",
-            "version": "v2.3.4",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/simplesamlphp/twig-configurable-i18n.git",
-                "reference": "e2bffc7eed3112a0b3870ef5b4da0fd74c7c4b8a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/simplesamlphp/twig-configurable-i18n/zipball/e2bffc7eed3112a0b3870ef5b4da0fd74c7c4b8a",
-                "reference": "e2bffc7eed3112a0b3870ef5b4da0fd74c7c4b8a",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.1",
-                "twig/extensions": "@dev"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^7.5",
-                "sensiolabs/security-checker": "~6.0.3",
-                "simplesamlphp/simplesamlphp-test-framework": "~0.1.2",
-                "squizlabs/php_codesniffer": "^3.5",
-                "twig/twig": "^2.13"
-            },
-            "type": "project",
-            "autoload": {
-                "psr-4": {
-                    "SimpleSAML\\TwigConfigurableI18n\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-2.1"
-            ],
-            "authors": [
-                {
-                    "name": "Jaime Perez",
-                    "email": "jaime.perez@uninett.no"
-                }
-            ],
-            "description": "This is an extension on top of Twig's i18n extension, allowing you to customize which functions to use for translations.",
-            "keywords": [
-                "extension",
-                "gettext",
-                "i18n",
-                "internationalization",
-                "translation",
-                "twig"
-            ],
-            "support": {
-                "issues": "https://github.com/simplesamlphp/twig-configurable-i18n/issues",
-                "source": "https://github.com/simplesamlphp/twig-configurable-i18n"
-            },
-            "time": "2020-08-27T12:51:10+00:00"
-        },
         {
             "name": "symfony/cache",
             "version": "v5.3.8",
@@ -2831,6 +2775,205 @@
             ],
             "time": "2021-08-26T08:00:08+00:00"
         },
+        {
+            "name": "symfony/translation-contracts",
+            "version": "v2.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/translation-contracts.git",
+                "reference": "95c812666f3e91db75385749fe219c5e494c7f95"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/95c812666f3e91db75385749fe219c5e494c7f95",
+                "reference": "95c812666f3e91db75385749fe219c5e494c7f95",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5"
+            },
+            "suggest": {
+                "symfony/translation-implementation": ""
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "2.4-dev"
+                },
+                "thanks": {
+                    "name": "symfony/contracts",
+                    "url": "https://github.com/symfony/contracts"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\Translation\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to translation",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/translation-contracts/tree/v2.4.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-03-23T23:28:01+00:00"
+        },
+        {
+            "name": "symfony/twig-bridge",
+            "version": "v5.3.7",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/twig-bridge.git",
+                "reference": "503e12aded4d5cbda4f8d1f3824c6a108119822f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/503e12aded4d5cbda4f8d1f3824c6a108119822f",
+                "reference": "503e12aded4d5cbda4f8d1f3824c6a108119822f",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "symfony/polyfill-php80": "^1.16",
+                "symfony/translation-contracts": "^1.1|^2",
+                "twig/twig": "^2.13|^3.0.4"
+            },
+            "conflict": {
+                "phpdocumentor/reflection-docblock": "<3.2.2",
+                "phpdocumentor/type-resolver": "<1.4.0",
+                "symfony/console": "<4.4",
+                "symfony/form": "<5.3",
+                "symfony/http-foundation": "<5.3",
+                "symfony/http-kernel": "<4.4",
+                "symfony/translation": "<5.2",
+                "symfony/workflow": "<5.2"
+            },
+            "require-dev": {
+                "doctrine/annotations": "^1.12",
+                "egulias/email-validator": "^2.1.10|^3",
+                "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
+                "symfony/asset": "^4.4|^5.0",
+                "symfony/console": "^4.4|^5.0",
+                "symfony/dependency-injection": "^4.4|^5.0",
+                "symfony/expression-language": "^4.4|^5.0",
+                "symfony/finder": "^4.4|^5.0",
+                "symfony/form": "^5.3",
+                "symfony/http-foundation": "^5.3",
+                "symfony/http-kernel": "^4.4|^5.0",
+                "symfony/intl": "^4.4|^5.0",
+                "symfony/mime": "^5.2",
+                "symfony/polyfill-intl-icu": "~1.0",
+                "symfony/property-info": "^4.4|^5.1",
+                "symfony/routing": "^4.4|^5.0",
+                "symfony/security-acl": "^2.8|^3.0",
+                "symfony/security-core": "^4.4|^5.0",
+                "symfony/security-csrf": "^4.4|^5.0",
+                "symfony/security-http": "^4.4|^5.0",
+                "symfony/serializer": "^5.2",
+                "symfony/stopwatch": "^4.4|^5.0",
+                "symfony/translation": "^5.2",
+                "symfony/web-link": "^4.4|^5.0",
+                "symfony/workflow": "^5.2",
+                "symfony/yaml": "^4.4|^5.0",
+                "twig/cssinliner-extra": "^2.12|^3",
+                "twig/inky-extra": "^2.12|^3",
+                "twig/markdown-extra": "^2.12|^3"
+            },
+            "suggest": {
+                "symfony/asset": "For using the AssetExtension",
+                "symfony/expression-language": "For using the ExpressionExtension",
+                "symfony/finder": "",
+                "symfony/form": "For using the FormExtension",
+                "symfony/http-kernel": "For using the HttpKernelExtension",
+                "symfony/routing": "For using the RoutingExtension",
+                "symfony/security-core": "For using the SecurityExtension",
+                "symfony/security-csrf": "For using the CsrfExtension",
+                "symfony/security-http": "For using the LogoutUrlExtension",
+                "symfony/stopwatch": "For using the StopwatchExtension",
+                "symfony/translation": "For using the TranslationExtension",
+                "symfony/var-dumper": "For using the DumpExtension",
+                "symfony/web-link": "For using the WebLinkExtension",
+                "symfony/yaml": "For using the YamlExtension"
+            },
+            "type": "symfony-bridge",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Bridge\\Twig\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides integration for Twig with various Symfony components",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/twig-bridge/tree/v5.3.7"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-08-26T07:28:06+00:00"
+        },
         {
             "name": "symfony/var-dumper",
             "version": "v5.3.8",
@@ -3067,66 +3210,6 @@
             ],
             "time": "2021-07-29T06:20:01+00:00"
         },
-        {
-            "name": "twig/extensions",
-            "version": "v1.5.4",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/twigphp/Twig-extensions.git",
-                "reference": "57873c8b0c1be51caa47df2cdb824490beb16202"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/twigphp/Twig-extensions/zipball/57873c8b0c1be51caa47df2cdb824490beb16202",
-                "reference": "57873c8b0c1be51caa47df2cdb824490beb16202",
-                "shasum": ""
-            },
-            "require": {
-                "twig/twig": "^1.27|^2.0"
-            },
-            "require-dev": {
-                "symfony/phpunit-bridge": "^3.4",
-                "symfony/translation": "^2.7|^3.4"
-            },
-            "suggest": {
-                "symfony/translation": "Allow the time_diff output to be translated"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.5-dev"
-                }
-            },
-            "autoload": {
-                "psr-0": {
-                    "Twig_Extensions_": "lib/"
-                },
-                "psr-4": {
-                    "Twig\\Extensions\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                }
-            ],
-            "description": "Common additional features for Twig that do not directly belong in core",
-            "keywords": [
-                "i18n",
-                "text"
-            ],
-            "support": {
-                "issues": "https://github.com/twigphp/Twig-extensions/issues",
-                "source": "https://github.com/twigphp/Twig-extensions/tree/master"
-            },
-            "abandoned": true,
-            "time": "2018-12-05T18:34:18+00:00"
-        },
         {
             "name": "twig/intl-extra",
             "version": "v3.3.3",
@@ -3198,16 +3281,16 @@
         },
         {
             "name": "twig/twig",
-            "version": "v2.14.7",
+            "version": "v3.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/twigphp/Twig.git",
-                "reference": "8e202327ee1ed863629de9b18a5ec70ac614d88f"
+                "reference": "a27fa056df8a6384316288ca8b0fa3a35fdeb569"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/twigphp/Twig/zipball/8e202327ee1ed863629de9b18a5ec70ac614d88f",
-                "reference": "8e202327ee1ed863629de9b18a5ec70ac614d88f",
+                "url": "https://api.github.com/repos/twigphp/Twig/zipball/a27fa056df8a6384316288ca8b0fa3a35fdeb569",
+                "reference": "a27fa056df8a6384316288ca8b0fa3a35fdeb569",
                 "shasum": ""
             },
             "require": {
@@ -3222,13 +3305,10 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.14-dev"
+                    "dev-master": "3.3-dev"
                 }
             },
             "autoload": {
-                "psr-0": {
-                    "Twig_": "lib/"
-                },
                 "psr-4": {
                     "Twig\\": "src/"
                 }
@@ -3261,7 +3341,7 @@
             ],
             "support": {
                 "issues": "https://github.com/twigphp/Twig/issues",
-                "source": "https://github.com/twigphp/Twig/tree/v2.14.7"
+                "source": "https://github.com/twigphp/Twig/tree/v3.3.3"
             },
             "funding": [
                 {
@@ -3273,7 +3353,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-09-17T08:39:54+00:00"
+            "time": "2021-09-17T08:44:23+00:00"
         },
         {
             "name": "webmozart/assert",
@@ -6441,6 +6521,7 @@
                 "issues": "https://github.com/webmozart/path-util/issues",
                 "source": "https://github.com/webmozart/path-util/tree/2.3.0"
             },
+            "abandoned": "symfony/filesystem",
             "time": "2015-12-17T08:42:14+00:00"
         }
     ],
diff --git a/lib/SimpleSAML/Locale/TwigTranslator.php b/lib/SimpleSAML/Locale/TwigTranslator.php
new file mode 100644
index 0000000000000000000000000000000000000000..4017bab9fbe39d5cc54affc69afe15a2f6416dae
--- /dev/null
+++ b/lib/SimpleSAML/Locale/TwigTranslator.php
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * Wrap twig trans to allow us to override the translator used.
+ *
+ * @package SimpleSAMLphp
+ */
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Locale;
+
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+class TwigTranslator implements TranslatorInterface
+{
+    private $translator;
+
+    /**
+     * @param callable|null $translator
+     */
+    public function __construct(callable $translator = null)
+    {
+        if (!is_callable($translator)) {
+            $translator = fn($string) => gettext($string);
+        }
+
+        $this->translator = $translator;
+    }
+
+    /**
+     * Translate message via configured translator.
+     *
+     * @param string $id
+     * @param array $parameters
+     * @param string|null $domain
+     * @param string|null $locale
+     */
+    public function trans(string $id, array $parameters = [], string $domain = null, string $locale = null)
+    {
+        $this->locale = $locale;
+
+        return call_user_func_array($this->translator, func_get_args());
+    }
+}
diff --git a/lib/SimpleSAML/XHTML/Template.php b/lib/SimpleSAML/XHTML/Template.php
index 35d29204d786df9688da18333f80c9218dc86595..e62062d14c5c84bfa460da7d378a95deb7db1953 100644
--- a/lib/SimpleSAML/XHTML/Template.php
+++ b/lib/SimpleSAML/XHTML/Template.php
@@ -15,12 +15,15 @@ use SimpleSAML\Configuration;
 use SimpleSAML\Locale\Language;
 use SimpleSAML\Locale\Localization;
 use SimpleSAML\Locale\Translate;
+use SimpleSAML\Locale\TwigTranslator;
 use SimpleSAML\Logger;
 use SimpleSAML\Module;
 use SimpleSAML\TwigConfigurableI18n\Twig\Environment as Twig_Environment;
 use SimpleSAML\TwigConfigurableI18n\Twig\Extensions\Extension\I18n as Twig_Extensions_Extension_I18n;
 use SimpleSAML\Utils;
+use Symfony\Bridge\Twig\Extension\TranslationExtension;
 use Symfony\Component\HttpFoundation\Response;
+use Twig\Environment;
 use Twig\Loader\FilesystemLoader;
 use Twig\TwigFilter;
 use Twig\TwigFunction;
@@ -263,7 +266,7 @@ class Template extends Response
      * @return \Twig\Environment
      * @throws \Exception if the template does not exist
      */
-    private function setupTwig(): \Twig\Environment
+    private function setupTwig(): Environment
     {
         $auto_reload = $this->configuration->getBoolean('template.auto_reload', true);
         $cache = $this->configuration->getString('template.cache', false);
@@ -289,12 +292,11 @@ class Template extends Response
             'auto_reload' => $auto_reload,
             'cache' => $cache,
             'strict_variables' => true,
-            'translation_function' => [Translate::class, 'translateSingularGettext'],
-            'translation_function_plural' => [Translate::class, 'translatePluralGettext'],
         ];
 
-        $twig = new Twig_Environment($loader, $options);
-        $twig->addExtension(new Twig_Extensions_Extension_I18n());
+        $twig = new Environment($loader, $options);
+        $twigTranslator = new TwigTranslator([Translate::class, 'translateSingularGettext']);
+        $twig->addExtension(new TranslationExtension($twigTranslator));
         $twig->addExtension(new \Twig\Extra\Intl\IntlExtension());
 
         $twig->addFunction(new TwigFunction('moduleURL', [Module::class, 'getModuleURL']));
diff --git a/modules/admin/templates/config.twig b/modules/admin/templates/config.twig
index 0fddf8fdbd7c698fc2d4f4d1b82050d475875393..0487bbc121c9de696bc3a6d0ec9465e85706f059 100644
--- a/modules/admin/templates/config.twig
+++ b/modules/admin/templates/config.twig
@@ -18,7 +18,9 @@
     <div class="message-box">
       {% trans %}SimpleSAMLphp is installed in:{% endtrans %}
       <kbd>{{ directory }}</kbd><br/>
-      {% trans %}You are running version <kbd>{{ version }}</kbd>.{% endtrans %}
+      {% trans with {
+          '%version%': version
+      } %}You are running version <kbd>%version%</kbd>.{% endtrans %}
     </div>
     <h2>{% trans %}Configuration{% endtrans %}</h2>
     <div class="enablebox mini">
diff --git a/modules/admin/templates/federation.twig b/modules/admin/templates/federation.twig
index 6ae2aa26f52982c222a84af993f43ec265321f76..492dd246bb16a87f4522ca9c554ec44bcc08ded2 100644
--- a/modules/admin/templates/federation.twig
+++ b/modules/admin/templates/federation.twig
@@ -56,8 +56,7 @@
               <div id="xml-{{ key }}" class="code-box-content xml">{{ set.metadata }}</div>
             </dd>
             <dt>{% trans %}SimpleSAMLphp Metadata{% endtrans %}</dt>
-            <dd>{% trans %}Use this if you are using a SimpleSAMLphp entity on
-              {#- #} the other side:{% endtrans %}</dd>
+            <dd>{% trans %}Use this if you are using a SimpleSAMLphp entity on the other side:{% endtrans %}</dd>
             <dd class="code-box hljs">
               <div class="pure-button-group top-right-corner">
                 <a class="pure-button copy hljs" data-clipboard-target="#php-{{ key }}"
diff --git a/modules/admin/templates/metadata_converter.twig b/modules/admin/templates/metadata_converter.twig
index 5c08911c1434723452dafd1d23e62c42508f9c03..5d37f58cdd07ee467aa8331fb38847e6e1c90fd3 100644
--- a/modules/admin/templates/metadata_converter.twig
+++ b/modules/admin/templates/metadata_converter.twig
@@ -8,7 +8,7 @@
 
     <h2>{{ pagetitle }}</h2>
     <form method="post" class="pure-form" enctype="multipart/form-data" action="#converted">
-        <h3> {% trans 'XML metadata' %}</h3>
+        <h3> {{ 'XML metadata'|trans }}</h3>
         <div class="pure-control-group">
             <textarea name="xmldata" rows="20" class="text-area edge xmldata">{{ xmldata }}</textarea>
         </div>
@@ -29,9 +29,10 @@
     {% if output -%}
     <br>
     <h2 id="converted">{{ 'Converted metadata'|trans }}</h2>
-        {% for type, text in output if text -%}
+        {% for type, text in output -%}
+            {%- if text -%}
 {# spaceless is to work around a clipboard.js bug that would add extra whitespace #}
-{% spaceless %}
+{% apply spaceless %}
     <div class="code-box">
         <div class="code-box-title">
             <h3>{{ type }}</h3>
@@ -43,9 +44,10 @@
             <pre id="metadata{{ loop.index }}">{{ text|escape }}</pre>
         </div>
     </div>
-{% endspaceless %}
+{% endapply %}
             <br><br>
             {%- set i=i+1 %}
+            {%- endif -%}
         {%- endfor -%}
     {% elseif error is not null %}
     <br>
diff --git a/modules/admin/templates/status.twig b/modules/admin/templates/status.twig
index 38fc86399ac6fc5fd7bc6326b61353f6c8d5569d..d68bd6e9a9215733929d7dd8aa73ee104f96d8d3 100644
--- a/modules/admin/templates/status.twig
+++ b/modules/admin/templates/status.twig
@@ -9,7 +9,9 @@
      it lasts until it times out and all the attributes that are attached to your session.{% endtrans %}</p>
 
     {% if remaining %}
-        <p>{% trans %}Your session is valid for {{ remaining }} seconds from now.{% endtrans %}</p>
+        <p>{% trans with {
+            '%remaining%': remaining
+        } %}Your session is valid for %remaining% seconds from now.{% endtrans %}</p>
     {% endif %}
 
     <h2>{{ '{status:attributes_header}'|trans }}</h2>
diff --git a/modules/core/templates/cardinality_error.twig b/modules/core/templates/cardinality_error.twig
index 30b864c0ff17cce812ef4f01ba4ac52220496e3e..a904218bda5ace68fe650ec7addd86e990c7ee02 100644
--- a/modules/core/templates/cardinality_error.twig
+++ b/modules/core/templates/cardinality_error.twig
@@ -4,9 +4,9 @@
 
 <h1>{{ pagetitle }}</h1>
 
-<p>{% trans 'One or more of the attributes supplied by your identity provider did not contain the expected number of values.' %}</p>
+<p>{{ 'One or more of the attributes supplied by your identity provider did not contain the expected number of values.'|trans }}</p>
 
-<h3>{% trans 'The problematic attribute(s) are:' %}</h3>
+<h3>{{ 'The problematic attribute(s) are:'|trans }}</h3>
 
 <dl class="cardinalityErrorAttributes">
 {% for attr,issues in cardinalityErrorAttributes %}
@@ -14,12 +14,15 @@
   {% set want = issues[1] %}
 
   <dt>{{ attr }}</dt>
-  <dd>{% trans %}got {{ got }} values, want {{ want }}{% endtrans %}</dd>
+  <dd>{% trans with {
+      '%got%': got,
+      '%want%': want
+  } %}got %got% values, want %want%{% endtrans %}</dd>
 {% endfor %}
 </dl>
 
 {% if LogoutURL is defined %}
-    <p><a href="{{ LogoutURL }}">{% trans 'Logout' %}</a></p>
+    <p><a href="{{ LogoutURL }}">{{ 'Logout'|trans }}</a></p>
 {% endif %}
 
 {% endblock %}
diff --git a/modules/core/templates/loginuserpass.twig b/modules/core/templates/loginuserpass.twig
index 3564ae6018e636fdcc55ed9109d7765f7975d2e2..2f0e4c051504a43f925aafbfaff00812fcb3f1d0 100644
--- a/modules/core/templates/loginuserpass.twig
+++ b/modules/core/templates/loginuserpass.twig
@@ -10,10 +10,7 @@
     {%- if not isProduction %}
 
     <div class="message-box warning">
-      {% trans %}You are now accessing a pre-production system. This authentication setup
-      {#- #} is for testing and pre-production verification only. If someone sent you
-      {#- #} a link that pointed you here, and you are not <i>a tester</i> you
-      {#- #} probably got the wrong link, and should <b>not be here</b>.{% endtrans %}
+      {% trans %}You are now accessing a pre-production system. This authentication setup is for testing and pre-production verification only. If someone sent you a link that pointed you here, and you are not <i>a tester</i> you probably got the wrong link, and should <b>not be here</b>.{% endtrans %}
     </div>
     {% endif -%}
     {% if errorcode -%}
diff --git a/modules/core/templates/logout-iframe.twig b/modules/core/templates/logout-iframe.twig
index e3959ba6282ceb0891b5dea7bb20067921ad25bb..7ed6e2c40283b9461c4aa58988671cecb462d439 100644
--- a/modules/core/templates/logout-iframe.twig
+++ b/modules/core/templates/logout-iframe.twig
@@ -20,7 +20,9 @@
   {%- if terminated_service %}
     {%- set SP = terminated_service['name']|translateFromArray|default('the service'|trans)|e %}
 
-    <p>{% trans %}You are now successfully logged out from {{ SP }}.{% endtrans %}</p>
+    <p>{% trans with {
+        '%SP%': SP
+    } %}You are now successfully logged out from %SP%.{% endtrans %}</p>
   {%- endif %}
   {%- if remaining_services %}
     {%- set failed = false  %}
@@ -74,8 +76,7 @@
     <br>
     <div id="error-message"{% if not failed or type == 'init' %} class="hidden"{% endif %}>
       <div class="message-box error">
-        {% trans %}Unable to log out of one or more services. To ensure that all your
-        {#- #} sessions are closed, you are encouraged to <i>close your webbrowser</i>.{% endtrans %}
+        {% trans %}Unable to log out of one or more services. To ensure that all your sessions are closed, you are encouraged to <i>close your webbrowser</i>.{% endtrans %}
       </div>
     </div>
     <form id="error-form" action="logout-iframe-done.php"
@@ -101,7 +102,9 @@
           <input type="hidden" name="id" value="{{ auth_state }}">
           <input type="hidden" name="cancel" value="">
           <button id="btn-cancel" class="pure-button" type="submit">
-            {%- if terminated_service %}{% trans %}No, only {{ SP }}{% endtrans %}
+            {%- if terminated_service %}{% trans  with {
+                '%SP%': SP
+            } %}No, only %SP%{% endtrans %}
             {%- else %}{% trans %}No{% endtrans %}{% endif -%}
           </button>
         </form>
diff --git a/modules/saml/templates/proxy/invalid_session.twig b/modules/saml/templates/proxy/invalid_session.twig
index 23161a1c8c35ca8aaabbcbb72696907190b710b4..a7e40beee8d3e21bf7c1cf2fb52a8e320bc6685b 100644
--- a/modules/saml/templates/proxy/invalid_session.twig
+++ b/modules/saml/templates/proxy/invalid_session.twig
@@ -3,7 +3,7 @@
 
 {% block content %}
     <h2>{{ 'Invalid Identity Provider'|trans }}</h2>
-    <p>{{ 'You already have a valid session with an identity provider (<em>%IDP%</em>) that is not accepted by <em>%SP%</em>. Would you like to log out from your existing session and log in again with another identity provider?'|trans({"%IDP%": entity_idp|entityDiplayName, "%SP%": entity_sp|entityDisplayName}, "app")|raw</p>
+    <p>{{ 'You already have a valid session with an identity provider (<em>%IDP%</em>) that is not accepted by <em>%SP%</em>. Would you like to log out from your existing session and log in again with another identity provider?'|trans({"%IDP%": entity_idp|entityDisplayName, "%SP%": entity_sp|entityDisplayName}, "app")|raw }}</p>
     <form method="post" action="?">
         <input type="hidden" name="AuthState" value="{{ AuthState|escape('html') }}">
         <input type="submit" name="continue" value="{{ 'Yes, continue'|trans }}">
diff --git a/templates/_table.twig b/templates/_table.twig
index 6af434995d808f3d2ca72ff0f279d6a94b91fcd5..2ada6a8a1c369f29edabee9aec4895b98a122c54 100644
--- a/templates/_table.twig
+++ b/templates/_table.twig
@@ -7,7 +7,7 @@
             <td class="attrname">{{ name }}</td>
         {%- endblock %}
 
-        <td class="attrvalue">{% spaceless %}
+        <td class="attrvalue">{% apply spaceless %}
                 {% for value in values %}
                     {% if loop.length>1 and loop.first %}<ul>{% endif %}
                     {% if loop.length>1 %}<li>{% endif -%}
@@ -17,7 +17,7 @@
                     {% if loop.length>1 %}</li>{% endif %}
                     {% if loop.length>1 and loop.last %}</ul>{% endif %}
                 {% endfor %}
-            {% endspaceless -%}
+            {% endapply -%}
         </td>
     </tr>
 {% endfor %}
diff --git a/templates/auth_status.twig b/templates/auth_status.twig
index 8a80f413d388b0f6618b9d948758c4274f9ccf13..5ba1be20ada060cf0f058a942d794462a93405bc 100644
--- a/templates/auth_status.twig
+++ b/templates/auth_status.twig
@@ -7,7 +7,9 @@
     <p>{% trans %}Hi, this is the status page of SimpleSAMLphp. Here you can see if your session is timed out, how long it lasts until it times out and all the attributes that are attached to your session.{% endtrans %}</p>
 
 {% if remaining %}
-    <p>{% trans %}Your session is valid for {{ remaining }} seconds from now.{% endtrans %}</p>
+    <p>{% trans with {
+        '%remaining%': remaining
+    } %}Your session is valid for %remaining% seconds from now.{% endtrans %}</p>
 {% endif %}
 
 
diff --git a/templates/base.twig b/templates/base.twig
index 8c8f6c012863e0ccad3abbfe928f3dc17c677d62..5c115e9fe5e835875776b744dd01078fdf6c3dd7 100644
--- a/templates/base.twig
+++ b/templates/base.twig
@@ -1,4 +1,4 @@
-{% spaceless %}
+{% apply spaceless %}
 <!DOCTYPE html>
 <html lang="{{ currentLanguage }}" xml:lang="{{ currentLanguage }}">
   <head>
@@ -31,4 +31,4 @@
     {% block postload %}{% endblock %}
   </body>
 </html>
-{% endspaceless %}
+{% endapply %}
diff --git a/templates/sandbox.twig b/templates/sandbox.twig
index 1ff8134fd7e0a2d08a1b1be18f7623549cce2ef3..f8a10a9dedf23ea42134efd49d8f2a07180527e1 100644
--- a/templates/sandbox.twig
+++ b/templates/sandbox.twig
@@ -21,10 +21,10 @@
     <p>Twig allows you to translate strings in your templates. There are several ways to do that. If you want
         to translate the following text: <em>Hello, Untranslated World!</em>, you can do it with:</p>
     <ul>
-        <li><em>Inline trans tags</em>: using <code>{{ '{%' }} trans 'Hello, Untranslated World! %}</code> you would get
-            "{% trans 'Hello, Untranslated World!' %}".</li>
-        <li><em>Expanded trans tags</em>: using <code>{{ '{%' }} trans %}Hello, Untranslated World!{{ '{%' }} endtrans
-                %}</code> you would get "{% trans %}Hello, Untranslated World!{% endtrans %}".</li>
+        <li><em>Inline trans tags</em>: using <code>{{ '{{' }} 'Hello, Untranslated World!'|trans {{ '}}' }}</code> you would get
+            "{{ 'Hello, Untranslated World!'|trans  }}".</li>
+        <li><em>Expanded trans tags</em>: using <code>{{ '{%' }} trans {{ '%}' }}Hello, Untranslated World!{{ '{%' }} endtrans
+                {{ '%}' }}</code> you would get "{% trans %}Hello, Untranslated World!{% endtrans %}".</li>
         <li><em>Filters</em>: using <code>{{ '{{' }} variable|trans }}</code> you would get "{{ variable|trans }}".</li>
     </ul>
     <p>Translations support arguments too, so that you can replace parts of the translated string with the contents of
diff --git a/tests/lib/SimpleSAML/XHTML/TemplateTranslationTest.php b/tests/lib/SimpleSAML/XHTML/TemplateTranslationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d4c2db9e22b2b35c799996adfa1103a7f872126a
--- /dev/null
+++ b/tests/lib/SimpleSAML/XHTML/TemplateTranslationTest.php
@@ -0,0 +1,152 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\XHTML;
+
+use Exception;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Configuration;
+use SimpleSAML\Locale\TwigTranslator;
+use SimpleSAML\Locale\Translate;
+use SimpleSAML\XHTML\Template;
+use Symfony\Bridge\Twig\Extension\TranslationExtension;
+use Symfony\Component\Finder\Finder;
+use Symfony\Component\Finder\SplFileInfo;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+
+/**
+ * @covers \SimpleSAML\XHTML\Template
+ */
+class TemplateTranslationTest extends TestCase
+{
+    public function testCoreCardinalityErrorTemplate(): void {
+        $c = Configuration::loadFromArray([], '', 'simplesaml');
+        $t = new Template($c, 'core:cardinality_error.twig');
+
+        $t->data['cardinalityErrorAttributes'] = [
+            'test 1' => [0, 1],
+            'test 2' => [1, 2],
+        ];
+
+        $getContent = function() {
+            /** @var Template $this */
+            return $this->getContents();
+        };
+        $html = $getContent->call($t);
+
+        $this->assertStringContainsString('got 0 values, want 1', $html);
+        $this->assertStringContainsString('got 1 values, want 2', $html);
+
+    }
+
+    public function testCoreLoginUserPassTemplate(): void {
+        $c = Configuration::loadFromArray([], '', 'simplesaml');
+        $t = new Template($c, 'core:loginuserpass.twig');
+
+        $t->data['isProduction'] = false;
+        $t->data['errorcode'] = false;
+        $t->data['forceUsername'] = false;
+        $t->data['username'] = 'h.c oersted';
+        $t->data['rememberUsernameEnabled'] = false;
+        $t->data['rememberMeEnabled'] = false;
+        $t->data['stateparams'] = [];
+
+        $getContent = function() {
+            /** @var Template $this */
+            return $this->getContents();
+        };
+        $html = $getContent->call($t);
+
+        $this->assertStringContainsString('value="h.c oersted"', $html);
+    }
+
+    public function testCoreLogoutIframeTemplate(): void {
+        $c = Configuration::loadFromArray([], '', 'simplesaml');
+        $t = new Template($c, 'core:logout-iframe.twig');
+
+        $t->data['auth_state'] = 'logout-test';
+        $t->data['type'] = 'test';
+        $t->data['terminated_service'] = [
+            'name' => [
+                'en' => 'ze testing service',
+            ],
+        ];
+        $t->data['remaining_services'] = [
+            'test' => [
+                'entityID' => 1234,
+                'metadata' => [
+                    'name' => [
+                        'en' => 'ze missing service',
+                    ],
+                ],
+                'status' => 'onhold',
+                'logoutURL' => 'https://xxx.yyy/',
+            ],
+        ];
+
+        $getContent = function() {
+            /** @var Template $this */
+            return $this->getContents();
+        };
+        $html = $getContent->call($t);
+
+        $this->assertStringContainsString('You are now successfully logged out from ze testing service.', $html);
+        $this->assertStringContainsString('ze missing service', $html);
+    }
+
+    public function testAuthStatusTemplate(): void {
+        $c = Configuration::loadFromArray([], '', 'simplesaml');
+        $t = new Template($c, 'auth_status.twig');
+
+        $t->data['remaining'] = 2;
+        $t->data['attributes'] = [];
+        $t->data['nameid'] = false;
+        $t->data['trackid'] = '';
+        $t->data['authData'] = false;
+
+        $getContent = function() {
+            /** @var Template $this */
+            return $this->getContents();
+        };
+        $html = $getContent->call($t);
+
+        $this->assertStringContainsString('Your session is valid for ' . $t->data['remaining'] . ' seconds from now.', $html);
+    }
+
+    public function testValidateTwigFiles()
+    {
+        $root = dirname(dirname((dirname(dirname(__DIR__)))));
+
+        // Setup basic twig environment
+        $loader = new \Twig\Loader\FilesystemLoader(['templates', 'modules'], $root);
+        $twig = new \Twig\Environment($loader, ['cache' => false]);
+
+        $twigTranslator = new TwigTranslator([Translate::class, 'translateSingularGettext']);
+        $twig->addExtension(new TranslationExtension($twigTranslator));
+        $twig->addExtension(new \Twig\Extra\Intl\IntlExtension());
+
+        // Fake functions
+        $twig->addFunction(new TwigFunction('asset', function() { return ''; }));
+        $twig->addFunction(new TwigFunction('moduleURL', function() { return ''; }));
+
+        // Fake filters
+        $twig->addFilter(new TwigFilter('translateFromArray', function() { return ''; }, ['needs_context' => true]));
+        $twig->addFilter(new TwigFilter('entityDisplayName', function() { return ''; }));
+
+        $files = Finder::create()
+            ->name('*.twig')
+            ->in([
+                $root . '/templates',
+                $root . '/modules'
+            ]);
+
+        foreach ($files as $file) {
+            /** @var SplFileInfo $file */
+            $twig->load($file->getRelativePathname());
+        }
+
+        $this->assertTrue(true, 'All *.twig files parsed load test.');
+    }
+}