diff --git a/composer.json b/composer.json
index 6888b1732672c1f32a064b925965a87a0d54d314..14deee4f7f938e55adbc2e9be55a4aad473a20a9 100644
--- a/composer.json
+++ b/composer.json
@@ -41,10 +41,15 @@
         "whitehat101/apr1-md5": "~1.0",
         "twig/twig": "~1.0 || ~2.0",
         "gettext/gettext": "^4.6",
-        "jaimeperez/twig-configurable-i18n": "^2.0"
+        "jaimeperez/twig-configurable-i18n": "^2.0",
+        "symfony/routing": "^3.4",
+        "symfony/http-foundation": "^3.4",
+        "symfony/config": "^3.4",
+        "symfony/http-kernel": "^3.4",
+        "symfony/dependency-injection": "^3.4"
     },
     "require-dev": {
-        "phpunit/phpunit": "~4.8.35",
+        "phpunit/phpunit": "~4.8",
         "mikey179/vfsStream": "~1.6"
     },
     "suggests": {
diff --git a/composer.lock b/composer.lock
index 33134f8ea9286fc6f6985e1cfdd40dd5c5663c89..4226822899ba13e2a46bb8abd248fd2aeec2cd0a 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": "a5ddd14915964c85316344e4f14c528e",
+    "content-hash": "9bb30d08fad0383d1455906ecf328ef3",
     "packages": [
         {
             "name": "gettext/gettext",
@@ -173,6 +173,100 @@
             ],
             "time": "2018-10-10T09:12:46+00:00"
         },
+        {
+            "name": "paragonie/random_compat",
+            "version": "v9.99.99",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/paragonie/random_compat.git",
+                "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/paragonie/random_compat/zipball/84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95",
+                "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "4.*|5.*",
+                "vimeo/psalm": "^1"
+            },
+            "suggest": {
+                "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
+            },
+            "type": "library",
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Paragon Initiative Enterprises",
+                    "email": "security@paragonie.com",
+                    "homepage": "https://paragonie.com"
+                }
+            ],
+            "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
+            "keywords": [
+                "csprng",
+                "polyfill",
+                "pseudorandom",
+                "random"
+            ],
+            "time": "2018-07-02T15:55:56+00:00"
+        },
+        {
+            "name": "psr/container",
+            "version": "1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/container.git",
+                "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
+                "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Container\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Common Container Interface (PHP FIG PSR-11)",
+            "homepage": "https://github.com/php-fig/container",
+            "keywords": [
+                "PSR-11",
+                "container",
+                "container-interface",
+                "container-interop",
+                "psr"
+            ],
+            "time": "2017-02-14T16:28:37+00:00"
+        },
         {
             "name": "psr/log",
             "version": "1.0.2",
@@ -315,9 +409,456 @@
             "description": "SAML2 PHP library from SimpleSAMLphp",
             "time": "2018-11-20T11:11:28+00:00"
         },
+        {
+            "name": "symfony/config",
+            "version": "v3.4.17",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/config.git",
+                "reference": "e5389132dc6320682de3643091121c048ff796b3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/config/zipball/e5389132dc6320682de3643091121c048ff796b3",
+                "reference": "e5389132dc6320682de3643091121c048ff796b3",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.5.9|>=7.0.8",
+                "symfony/filesystem": "~2.8|~3.0|~4.0",
+                "symfony/polyfill-ctype": "~1.8"
+            },
+            "conflict": {
+                "symfony/dependency-injection": "<3.3",
+                "symfony/finder": "<3.3"
+            },
+            "require-dev": {
+                "symfony/dependency-injection": "~3.3|~4.0",
+                "symfony/event-dispatcher": "~3.3|~4.0",
+                "symfony/finder": "~3.3|~4.0",
+                "symfony/yaml": "~3.0|~4.0"
+            },
+            "suggest": {
+                "symfony/yaml": "To use the yaml reference dumper"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.4-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Config\\": ""
+                },
+                "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": "Symfony Config Component",
+            "homepage": "https://symfony.com",
+            "time": "2018-09-08T13:15:14+00:00"
+        },
+        {
+            "name": "symfony/debug",
+            "version": "v3.4.17",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/debug.git",
+                "reference": "0a612e9dfbd2ccce03eb174365f31ecdca930ff6"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/debug/zipball/0a612e9dfbd2ccce03eb174365f31ecdca930ff6",
+                "reference": "0a612e9dfbd2ccce03eb174365f31ecdca930ff6",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.5.9|>=7.0.8",
+                "psr/log": "~1.0"
+            },
+            "conflict": {
+                "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2"
+            },
+            "require-dev": {
+                "symfony/http-kernel": "~2.8|~3.0|~4.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.4-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Debug\\": ""
+                },
+                "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": "Symfony Debug Component",
+            "homepage": "https://symfony.com",
+            "time": "2018-10-02T16:33:53+00:00"
+        },
+        {
+            "name": "symfony/dependency-injection",
+            "version": "v3.4.17",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/dependency-injection.git",
+                "reference": "aea20fef4e92396928b5db175788b90234c0270d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/aea20fef4e92396928b5db175788b90234c0270d",
+                "reference": "aea20fef4e92396928b5db175788b90234c0270d",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.5.9|>=7.0.8",
+                "psr/container": "^1.0"
+            },
+            "conflict": {
+                "symfony/config": "<3.3.7",
+                "symfony/finder": "<3.3",
+                "symfony/proxy-manager-bridge": "<3.4",
+                "symfony/yaml": "<3.4"
+            },
+            "provide": {
+                "psr/container-implementation": "1.0"
+            },
+            "require-dev": {
+                "symfony/config": "~3.3|~4.0",
+                "symfony/expression-language": "~2.8|~3.0|~4.0",
+                "symfony/yaml": "~3.4|~4.0"
+            },
+            "suggest": {
+                "symfony/config": "",
+                "symfony/expression-language": "For using expressions in service container configuration",
+                "symfony/finder": "For using double-star glob patterns or when GLOB_BRACE portability is required",
+                "symfony/proxy-manager-bridge": "Generate service proxies to lazy load them",
+                "symfony/yaml": ""
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.4-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\DependencyInjection\\": ""
+                },
+                "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": "Symfony DependencyInjection Component",
+            "homepage": "https://symfony.com",
+            "time": "2018-10-02T12:28:39+00:00"
+        },
+        {
+            "name": "symfony/event-dispatcher",
+            "version": "v3.4.17",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/event-dispatcher.git",
+                "reference": "b2e1f19280c09a42dc64c0b72b80fe44dd6e88fb"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b2e1f19280c09a42dc64c0b72b80fe44dd6e88fb",
+                "reference": "b2e1f19280c09a42dc64c0b72b80fe44dd6e88fb",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.5.9|>=7.0.8"
+            },
+            "conflict": {
+                "symfony/dependency-injection": "<3.3"
+            },
+            "require-dev": {
+                "psr/log": "~1.0",
+                "symfony/config": "~2.8|~3.0|~4.0",
+                "symfony/dependency-injection": "~3.3|~4.0",
+                "symfony/expression-language": "~2.8|~3.0|~4.0",
+                "symfony/stopwatch": "~2.8|~3.0|~4.0"
+            },
+            "suggest": {
+                "symfony/dependency-injection": "",
+                "symfony/http-kernel": ""
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.4-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\EventDispatcher\\": ""
+                },
+                "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": "Symfony EventDispatcher Component",
+            "homepage": "https://symfony.com",
+            "time": "2018-07-26T09:06:28+00:00"
+        },
+        {
+            "name": "symfony/filesystem",
+            "version": "v3.4.17",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/filesystem.git",
+                "reference": "d69930fc337d767607267d57c20a7403d0a822a4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/d69930fc337d767607267d57c20a7403d0a822a4",
+                "reference": "d69930fc337d767607267d57c20a7403d0a822a4",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.5.9|>=7.0.8",
+                "symfony/polyfill-ctype": "~1.8"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.4-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Filesystem\\": ""
+                },
+                "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": "Symfony Filesystem Component",
+            "homepage": "https://symfony.com",
+            "time": "2018-10-02T12:28:39+00:00"
+        },
+        {
+            "name": "symfony/http-foundation",
+            "version": "v3.4.17",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/http-foundation.git",
+                "reference": "3a4498236ade473c52b92d509303e5fd1b211ab1"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/3a4498236ade473c52b92d509303e5fd1b211ab1",
+                "reference": "3a4498236ade473c52b92d509303e5fd1b211ab1",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.5.9|>=7.0.8",
+                "symfony/polyfill-mbstring": "~1.1",
+                "symfony/polyfill-php70": "~1.6"
+            },
+            "require-dev": {
+                "symfony/expression-language": "~2.8|~3.0|~4.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.4-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\HttpFoundation\\": ""
+                },
+                "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": "Symfony HttpFoundation Component",
+            "homepage": "https://symfony.com",
+            "time": "2018-10-03T08:48:18+00:00"
+        },
+        {
+            "name": "symfony/http-kernel",
+            "version": "v3.4.17",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/http-kernel.git",
+                "reference": "a0944a9a1d8845da724236cde9a310964acadb1c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/a0944a9a1d8845da724236cde9a310964acadb1c",
+                "reference": "a0944a9a1d8845da724236cde9a310964acadb1c",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.5.9|>=7.0.8",
+                "psr/log": "~1.0",
+                "symfony/debug": "~2.8|~3.0|~4.0",
+                "symfony/event-dispatcher": "~2.8|~3.0|~4.0",
+                "symfony/http-foundation": "~3.4.12|~4.0.12|^4.1.1",
+                "symfony/polyfill-ctype": "~1.8"
+            },
+            "conflict": {
+                "symfony/config": "<2.8",
+                "symfony/dependency-injection": "<3.4.10|<4.0.10,>=4",
+                "symfony/var-dumper": "<3.3",
+                "twig/twig": "<1.34|<2.4,>=2"
+            },
+            "provide": {
+                "psr/log-implementation": "1.0"
+            },
+            "require-dev": {
+                "psr/cache": "~1.0",
+                "symfony/browser-kit": "~2.8|~3.0|~4.0",
+                "symfony/class-loader": "~2.8|~3.0",
+                "symfony/config": "~2.8|~3.0|~4.0",
+                "symfony/console": "~2.8|~3.0|~4.0",
+                "symfony/css-selector": "~2.8|~3.0|~4.0",
+                "symfony/dependency-injection": "^3.4.10|^4.0.10",
+                "symfony/dom-crawler": "~2.8|~3.0|~4.0",
+                "symfony/expression-language": "~2.8|~3.0|~4.0",
+                "symfony/finder": "~2.8|~3.0|~4.0",
+                "symfony/process": "~2.8|~3.0|~4.0",
+                "symfony/routing": "~3.4|~4.0",
+                "symfony/stopwatch": "~2.8|~3.0|~4.0",
+                "symfony/templating": "~2.8|~3.0|~4.0",
+                "symfony/translation": "~2.8|~3.0|~4.0",
+                "symfony/var-dumper": "~3.3|~4.0"
+            },
+            "suggest": {
+                "symfony/browser-kit": "",
+                "symfony/config": "",
+                "symfony/console": "",
+                "symfony/dependency-injection": "",
+                "symfony/finder": "",
+                "symfony/var-dumper": ""
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.4-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\HttpKernel\\": ""
+                },
+                "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": "Symfony HttpKernel Component",
+            "homepage": "https://symfony.com",
+            "time": "2018-10-03T12:03:34+00:00"
+        },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.10.0",
+            "version": "v1.9.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
@@ -375,16 +916,16 @@
         },
         {
             "name": "symfony/polyfill-mbstring",
-            "version": "v1.10.0",
+            "version": "v1.9.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "c79c051f5b3a46be09205c73b80b346e4153e494"
+                "reference": "d0cd638f4634c16d8df4508e847f14e9e43168b8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/c79c051f5b3a46be09205c73b80b346e4153e494",
-                "reference": "c79c051f5b3a46be09205c73b80b346e4153e494",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d0cd638f4634c16d8df4508e847f14e9e43168b8",
+                "reference": "d0cd638f4634c16d8df4508e847f14e9e43168b8",
                 "shasum": ""
             },
             "require": {
@@ -430,7 +971,143 @@
                 "portable",
                 "shim"
             ],
-            "time": "2018-09-21T13:07:52+00:00"
+            "time": "2018-08-06T14:22:27+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php70",
+            "version": "v1.9.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php70.git",
+                "reference": "1e24b0c4a56d55aaf368763a06c6d1c7d3194934"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/1e24b0c4a56d55aaf368763a06c6d1c7d3194934",
+                "reference": "1e24b0c4a56d55aaf368763a06c6d1c7d3194934",
+                "shasum": ""
+            },
+            "require": {
+                "paragonie/random_compat": "~1.0|~2.0|~9.99",
+                "php": ">=5.3.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.9-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php70\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ],
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "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": "Symfony polyfill backporting some PHP 7.0+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "time": "2018-08-06T14:22:27+00:00"
+        },
+        {
+            "name": "symfony/routing",
+            "version": "v3.4.17",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/routing.git",
+                "reference": "585f6e2d740393d546978769dd56e496a6233e0b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/routing/zipball/585f6e2d740393d546978769dd56e496a6233e0b",
+                "reference": "585f6e2d740393d546978769dd56e496a6233e0b",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.5.9|>=7.0.8"
+            },
+            "conflict": {
+                "symfony/config": "<3.3.1",
+                "symfony/dependency-injection": "<3.3",
+                "symfony/yaml": "<3.4"
+            },
+            "require-dev": {
+                "doctrine/annotations": "~1.0",
+                "psr/log": "~1.0",
+                "symfony/config": "^3.3.1|~4.0",
+                "symfony/dependency-injection": "~3.3|~4.0",
+                "symfony/expression-language": "~2.8|~3.0|~4.0",
+                "symfony/http-foundation": "~2.8|~3.0|~4.0",
+                "symfony/yaml": "~3.4|~4.0"
+            },
+            "suggest": {
+                "doctrine/annotations": "For using the annotation loader",
+                "symfony/config": "For using the all-in-one router or any loader",
+                "symfony/dependency-injection": "For loading routes from a service",
+                "symfony/expression-language": "For using expression matching",
+                "symfony/http-foundation": "For using a Symfony Request object",
+                "symfony/yaml": "For using the YAML loader"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.4-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Routing\\": ""
+                },
+                "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": "Symfony Routing Component",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "router",
+                "routing",
+                "uri",
+                "url"
+            ],
+            "time": "2018-10-02T12:28:39+00:00"
         },
         {
             "name": "twig/extensions",
diff --git a/config-templates/config.php b/config-templates/config.php
index 1fa9d3b40f8d1dfd764b319413a0266452135336..09117a875692dbf102ced407c8b185d262346c51 100644
--- a/config-templates/config.php
+++ b/config-templates/config.php
@@ -727,17 +727,6 @@ $config = [
     'language.cookie.httponly' => false,
     'language.cookie.lifetime' => (60 * 60 * 24 * 900),
 
-    /*
-     * Which i18n backend to use.
-     *
-     * "SimpleSAMLphp" is the home made system, valid for 1.x.
-     * For 2.x, only "gettext/gettext" will be possible.
-     *
-     * Home-made templates will always use "SimpleSAMLphp".
-     * To use twig (where avaliable), select "gettext/gettext".
-     */
-    'language.i18n.backend' => 'SimpleSAMLphp',
-
     /**
      * Custom getLanguage function called from SimpleSAML\Locale\Language::getLanguage().
      * Function should return language code of one of the available languages or NULL.
diff --git a/lib/SimpleSAML/Auth/AuthenticationFactory.php b/lib/SimpleSAML/Auth/AuthenticationFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..7335bc2d5643ba76e6d76c5a524ce9af444fe53c
--- /dev/null
+++ b/lib/SimpleSAML/Auth/AuthenticationFactory.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace SimpleSAML\Auth;
+
+/**
+ * Factory class to get instances of \SimpleSAML\Auth\Simple for a given authentication source.
+ */
+class AuthenticationFactory
+{
+
+    /** @var \SimpleSAML\Configuration */
+    protected $config;
+
+    /** @var \SimpleSAML\Session */
+    protected $session;
+
+
+    public function __construct(\SimpleSAML\Configuration $config, \SimpleSAML\Session $session)
+    {
+        $this->config = $config;
+        $this->session = $session;
+    }
+
+
+    /**
+     * Create a new instance of \SimpleSAML\Auth\Simple for the given authentication source.
+     *
+     * @param string $as The identifier of the authentication source, as indexed in the authsources.php configuration
+     * file.
+     *
+     * @return \SimpleSAML\Auth\Simple
+     */
+    public function create($as)
+    {
+        return new Simple($as, $this->config, $this->session);
+    }
+}
\ No newline at end of file
diff --git a/lib/SimpleSAML/Auth/Simple.php b/lib/SimpleSAML/Auth/Simple.php
index a0a0aae44a41eb9771b95bb41b85d49c8f3b89a7..ffaa0cc92a2921fcd53cc9f472cde8fba3a64dbf 100644
--- a/lib/SimpleSAML/Auth/Simple.php
+++ b/lib/SimpleSAML/Auth/Simple.php
@@ -23,22 +23,34 @@ class Simple
      */
     protected $authSource;
 
-    /**
-     * @var Configuration|null
-     */
+    /** @var \SimpleSAML\Configuration */
     protected $app_config;
 
+    /** @var \SimpleSAML\Session */
+    protected $session;
+
+
     /**
      * Create an instance with the specified authsource.
      *
      * @param string $authSource The id of the authentication source.
+     * @param \SimpleSAML\Configuration|null $config Optional configuration to use.
+     * @param \SimpleSAML\Session|null $session Optional session to use.
      */
-    public function __construct($authSource)
+    public function __construct($authSource, Configuration $config = null, Session $session = null)
     {
         assert(is_string($authSource));
 
+        if ($config === null) {
+            $config = Configuration::getInstance();
+        }
         $this->authSource = $authSource;
-        $this->app_config = Configuration::getInstance()->getConfigItem('application', null);
+        $this->app_config = $config->getConfigItem('application', null);
+
+        if ($session === null) {
+            $session = Session::getSessionFromRequest();
+        }
+        $this->session = $session;
     }
 
 
@@ -69,9 +81,7 @@ class Simple
      */
     public function isAuthenticated()
     {
-        $session = Session::getSessionFromRequest();
-
-        return $session->isValid($this->authSource);
+        return $this->session->isValid($this->authSource);
     }
 
 
@@ -90,9 +100,7 @@ class Simple
      */
     public function requireAuth(array $params = [])
     {
-        $session = Session::getSessionFromRequest();
-
-        if ($session->isValid($this->authSource)) {
+        if ($this->session->isValid($this->authSource)) {
             // Already authenticated
             return;
         }
@@ -195,14 +203,13 @@ class Simple
             assert(isset($params['ReturnStateParam'], $params['ReturnStateStage']));
         }
 
-        $session = Session::getSessionFromRequest();
-        if ($session->isValid($this->authSource)) {
-            $state = $session->getAuthData($this->authSource, 'LogoutState');
+        if ($this->session->isValid($this->authSource)) {
+            $state = $this->session->getAuthData($this->authSource, 'LogoutState');
             if ($state !== null) {
                 $params = array_merge($state, $params);
             }
 
-            $session->doLogout($this->authSource);
+            $this->session->doLogout($this->authSource);
 
             $params['LogoutCompletedHandler'] = [get_class(), 'logoutCompleted'];
 
@@ -259,8 +266,7 @@ class Simple
         }
 
         // Authenticated
-        $session = Session::getSessionFromRequest();
-        return $session->getAuthData($this->authSource, 'Attributes');
+        return $this->session->getAuthData($this->authSource, 'Attributes');
     }
 
 
@@ -279,8 +285,7 @@ class Simple
             return null;
         }
 
-        $session = Session::getSessionFromRequest();
-        return $session->getAuthData($this->authSource, $name);
+        return $this->session->getAuthData($this->authSource, $name);
     }
 
 
@@ -295,8 +300,7 @@ class Simple
             return null;
         }
 
-        $session = Session::getSessionFromRequest();
-        return $session->getAuthState($this->authSource);
+        return $this->session->getAuthState($this->authSource);
     }
 
 
diff --git a/lib/SimpleSAML/HTTP/Router.php b/lib/SimpleSAML/HTTP/Router.php
new file mode 100644
index 0000000000000000000000000000000000000000..e537092a9e62352497e49233f610a9e4a3a93a13
--- /dev/null
+++ b/lib/SimpleSAML/HTTP/Router.php
@@ -0,0 +1,127 @@
+<?php
+
+namespace SimpleSAML\HTTP;
+
+use SimpleSAML\Configuration;
+use SimpleSAML\Session;
+
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Controller\ArgumentResolver;
+use Symfony\Component\HttpKernel\HttpKernel;
+use Symfony\Component\Routing\RequestContext;
+
+/**
+ * Class that routes requests to responses.
+ *
+ * @package SimpleSAML
+ */
+class Router
+{
+
+    protected $arguments;
+
+    /** @var \SimpleSAML\Configuration */
+    protected $config;
+
+    /** @var RequestContext */
+    protected $context;
+
+    /** @var EventDispatcher */
+    protected $dispatcher;
+
+    /** @var Request */
+    protected $request;
+
+    /** @var \SimpleSAML\Module\ControllerResolver */
+    protected $resolver;
+
+    /** @var \SimpleSAML\Session */
+    protected $session;
+
+    /** @var RequestStack */
+    protected $stack;
+
+
+    /**
+     * Router constructor.
+     *
+     * @param string $module
+     */
+    public function __construct($module)
+    {
+        $this->arguments = new ArgumentResolver();
+        $this->context = new RequestContext();
+        $this->resolver = new \SimpleSAML\Module\ControllerResolver($module);
+        $this->dispatcher = new EventDispatcher();
+    }
+
+
+    /**
+     * Process a given request.
+     *
+     * If no specific arguments are given, the default instances will be used (configuration, session, etc).
+     *
+     * @param Request|null $request The request to process. Defaults to the current one.
+     *
+     * @return Response A response suitable for the given request.
+     *
+     * @throws \Exception If an error occurs.
+     */
+    public function process(Request $request = null)
+    {
+        if ($this->config === null) {
+            $this->setConfiguration(Configuration::getInstance());
+        }
+        if ($this->session === null) {
+            $this->setSession(Session::getSessionFromRequest());
+        }
+        $this->request = $request;
+        if ($request === null) {
+            $this->request = Request::createFromGlobals();
+        }
+        $stack = new RequestStack();
+        $stack->push($this->request);
+        $this->context->fromRequest($this->request);
+        $kernel = new HttpKernel($this->dispatcher, $this->resolver, $stack, $this->resolver);
+        return $kernel->handle($this->request);
+    }
+
+
+    /**
+     * Send a given response to the browser.
+     *
+     * @param Response $response The response to send.
+     */
+    public function send(Response $response)
+    {
+        $response->prepare($this->request);
+        $response->send();
+    }
+
+
+    /**
+     * Set the configuration to use by the controller.
+     *
+     * @param \SimpleSAML\Configuration $config
+     */
+    public function setConfiguration(Configuration $config)
+    {
+        $this->config = $config;
+        $this->resolver->setConfiguration($config);
+    }
+
+
+    /**
+     * Set the session to use by the controller.
+     *
+     * @param \SimpleSAML\Session $session
+     */
+    public function setSession(Session $session)
+    {
+        $this->session = $session;
+        $this->resolver->setSession($session);
+    }
+}
diff --git a/lib/SimpleSAML/HTTP/RunnableResponse.php b/lib/SimpleSAML/HTTP/RunnableResponse.php
new file mode 100644
index 0000000000000000000000000000000000000000..ab9fc6c5f14ed6b31a7c16522de1b5e1033bd227
--- /dev/null
+++ b/lib/SimpleSAML/HTTP/RunnableResponse.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace SimpleSAML\HTTP;
+
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Class modelling a response that consists on running some function.
+ *
+ * This is a helper class that allows us to have the new and the old architecture coexist. This way, classes and files
+ * that aren't PSR-7-aware can still be plugged into a PSR-7-compatible environment.
+ *
+ * @package SimpleSAML
+ */
+class RunnableResponse extends Response
+{
+    /** @var array */
+    protected $arguments;
+
+    /** @var callable */
+    protected $callable;
+
+
+    /**
+     * RunnableResponse constructor.
+     *
+     * @param callable $callable A callable that we should run as part of this response.
+     * @param array $args An array of arguments to be passed to the callable. Note that each element of the array
+     */
+    public function __construct(callable $callable, $args = [])
+    {
+        $this->arguments = $args;
+        $this->callable = $callable;
+        parent::__construct();
+    }
+
+
+    /**
+     * Get the callable for this response.
+     *
+     * @return callable
+     */
+    public function getCallable()
+    {
+        return $this->callable;
+    }
+
+
+    /**
+     * Get the arguments to the callable.
+     *
+     * @return array
+     */
+    public function getArguments()
+    {
+        return $this->arguments;
+    }
+
+
+    /**
+     * "Send" this response by actually running the callable.
+     *
+     * @return mixed
+     */
+    public function send()
+    {
+        return call_user_func_array($this->callable, $this->arguments);
+    }
+}
diff --git a/lib/SimpleSAML/Locale/Localization.php b/lib/SimpleSAML/Locale/Localization.php
index 9a4543d37ae95fc21553d8653eaf39867440179d..66427da457578cc1cb8ea9e4f398bf0e1d3bae58 100644
--- a/lib/SimpleSAML/Locale/Localization.php
+++ b/lib/SimpleSAML/Locale/Localization.php
@@ -96,7 +96,7 @@ class Localization
         $this->localeDir = $this->configuration->resolvePath('locales');
         $this->language = new Language($configuration);
         $this->langcode = $this->language->getPosixLanguage($this->language->getLanguage());
-        $this->i18nBackend = $this->configuration->getString('language.i18n.backend', self::SSP_I18N_BACKEND);
+        $this->i18nBackend = ($this->configuration->getBoolean('usenewui', false) ? self::GETTEXT_I18N_BACKEND : self::SSP_I18N_BACKEND);
         $this->setupL10N();
     }
 
diff --git a/lib/SimpleSAML/Module.php b/lib/SimpleSAML/Module.php
index 9668a367306afef04e8a10f8cd7561d925456fdc..5e1488ff07b828cf2529bbde9ee09e593df0ee86 100644
--- a/lib/SimpleSAML/Module.php
+++ b/lib/SimpleSAML/Module.php
@@ -2,6 +2,12 @@
 
 namespace SimpleSAML;
 
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\ResponseHeaderBag;
+
 /**
  * Helper class for accessing information about modules.
  *
@@ -12,6 +18,44 @@ namespace SimpleSAML;
  */
 class Module
 {
+
+    /**
+     * Index pages: file names to attempt when accessing directories.
+     *
+     * @var array
+     */
+    public static $indexFiles = ['index.php', 'index.html', 'index.htm', 'index.txt'];
+
+    /**
+     * MIME Types
+     *
+     * The key is the file extension and the value the corresponding MIME type.
+     *
+     * @var array
+     */
+    public static $mimeTypes = [
+        'bmp'   => 'image/x-ms-bmp',
+        'css'   => 'text/css',
+        'gif'   => 'image/gif',
+        'htm'   => 'text/html',
+        'html'  => 'text/html',
+        'shtml' => 'text/html',
+        'ico'   => 'image/vnd.microsoft.icon',
+        'jpe'   => 'image/jpeg',
+        'jpeg'  => 'image/jpeg',
+        'jpg'   => 'image/jpeg',
+        'js'    => 'text/javascript',
+        'pdf'   => 'application/pdf',
+        'png'   => 'image/png',
+        'svg'   => 'image/svg+xml',
+        'svgz'  => 'image/svg+xml',
+        'swf'   => 'application/x-shockwave-flash',
+        'swfl'  => 'application/x-shockwave-flash',
+        'txt'   => 'text/plain',
+        'xht'   => 'application/xhtml+xml',
+        'xhtml' => 'application/xhtml+xml',
+    ];
+
     /**
      * A list containing the modules currently installed.
      *
@@ -63,6 +107,172 @@ class Module
     }
 
 
+    /**
+     * Handler for module requests.
+     *
+     * This controller receives requests for pages hosted by modules, and processes accordingly. Depending on the
+     * configuration and the actual request, it will run a PHP script and exit, or return a Response produced either
+     * by another controller or by a static file.
+     *
+     * @param Request|null $request The request to process. Defaults to the current one.
+     *
+     * @return Response|BinaryFileResponse Returns a Response object that can be sent to the browser.
+     * @throws Error\BadRequest In case the request URI is malformed.
+     * @throws Error\NotFound In case the request URI is invalid or the resource it points to cannot be found.
+     */
+    public static function process(Request $request = null)
+    {
+        if ($request === null) {
+            $request = Request::createFromGlobals();
+        }
+
+        if ($request->getPathInfo() === '/') {
+            throw new Error\NotFound('No PATH_INFO to module.php');
+        }
+
+        $url = $request->getPathInfo();
+        assert(substr($url, 0, 1) === '/');
+
+        /* clear the PATH_INFO option, so that a script can detect whether it is called with anything following the
+         *'.php'-ending.
+         */
+        unset($_SERVER['PATH_INFO']);
+
+        $modEnd = strpos($url, '/', 1);
+        if ($modEnd === false) {
+            // the path must always be on the form /module/
+            throw new Error\NotFound('The URL must at least contain a module name followed by a slash.');
+        }
+
+        $module = substr($url, 1, $modEnd - 1);
+        $url = substr($url, $modEnd + 1);
+        if ($url === false) {
+            $url = '';
+        }
+
+        if (!self::isModuleEnabled($module)) {
+            throw new Error\NotFound('The module \''.$module.'\' was either not found, or wasn\'t enabled.');
+        }
+
+        /* Make sure that the request isn't suspicious (contains references to current directory or parent directory or
+         * anything like that. Searching for './' in the URL will detect both '../' and './'. Searching for '\' will
+         * detect attempts to use Windows-style paths.
+         */
+        if (strpos($url, '\\') !== false) {
+            throw new Error\BadRequest('Requested URL contained a backslash.');
+        } elseif (strpos($url, './') !== false) {
+            throw new Error\BadRequest('Requested URL contained \'./\'.');
+        }
+
+        $config = Configuration::getInstance();
+        if ($config->getBoolean('usenewui', false) === true) {
+            $router = new HTTP\Router($module);
+            try {
+                return $router->process();
+            } catch (\Symfony\Component\Config\Exception\FileLocatorFileNotFoundException $e) {
+                // no routes configured for this module, fall back to the old system
+            } catch (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException $e) {
+                // this module has been migrated, but the route wasn't found
+            }
+        }
+
+        $moduleDir = self::getModuleDir($module).'/www/';
+
+        // check for '.php/' in the path, the presence of which indicates that another php-script should handle the
+        // request
+        for ($phpPos = strpos($url, '.php/'); $phpPos !== false; $phpPos = strpos($url, '.php/', $phpPos + 1)) {
+            $newURL = substr($url, 0, $phpPos + 4);
+            $param = substr($url, $phpPos + 4);
+
+            if (is_file($moduleDir.$newURL)) {
+                /* $newPath points to a normal file. Point execution to that file, and save the remainder of the path
+                 * in PATH_INFO.
+                 */
+                $url = $newURL;
+                $request->server->set('PATH_INFO', $param);
+                $_SERVER['PATH_INFO'] = $param;
+                break;
+            }
+        }
+
+        $path = $moduleDir.$url;
+
+        if ($path[strlen($path) - 1] === '/') {
+            // path ends with a slash - directory reference. Attempt to find index file in directory
+            foreach (self::$indexFiles as $if) {
+                if (file_exists($path.$if)) {
+                    $path .= $if;
+                    break;
+                }
+            }
+        }
+
+        if (is_dir($path)) {
+            /* Path is a directory - maybe no index file was found in the previous step, or maybe the path didn't end
+             * with a slash. Either way, we don't do directory listings.
+             */
+            throw new Error\NotFound('Directory listing not available.');
+        }
+
+        if (!file_exists($path)) {
+            // file not found
+            Logger::info('Could not find file \''.$path.'\'.');
+            throw new Error\NotFound('The URL wasn\'t found in the module.');
+        }
+
+        if (substr($path, -4) === '.php') {
+            // PHP file - attempt to run it
+
+            /* In some environments, $_SERVER['SCRIPT_NAME'] is already set with $_SERVER['PATH_INFO']. Check for that
+             * case, and append script name only if necessary.
+             *
+             * Contributed by Travis Hegner.
+             */
+            $script = "/$module/$url";
+            if (strpos($request->getScriptName(), $script) === false) {
+                $request->server->set('SCRIPT_NAME', $request->getScriptName().'/'.$module.'/'.$url);
+            }
+
+            require($path);
+            exit();
+        }
+
+        // some other file type - attempt to serve it
+
+        // find MIME type for file, based on extension
+        $contentType = null;
+        if (preg_match('#\.([^/\.]+)$#D', $path, $type)) {
+            $type = strtolower($type[1]);
+            if (array_key_exists($type, self::$mimeTypes)) {
+                $contentType = self::$mimeTypes[$type];
+            }
+        }
+
+        if ($contentType === null) {
+            /* We were unable to determine the MIME type from the file extension. Fall back to mime_content_type (if it
+             * exists).
+             */
+            if (function_exists('mime_content_type')) {
+                $contentType = mime_content_type($path);
+            } else {
+                // mime_content_type doesn't exist. Return a default MIME type
+                Logger::warning('Unable to determine mime content type of file: '.$path);
+                $contentType = 'application/octet-stream';
+            }
+        }
+
+        $response = new BinaryFileResponse($path);
+        $response->setCache(['public' => true, 'max_age' => 86400]);
+        $response->setExpires(new \DateTime(gmdate('D, j M Y H:i:s \G\M\T', time() + 10 * 60)));
+        $response->setLastModified(new \DateTime(gmdate('D, j M Y H:i:s \G\M\T', filemtime($path))));
+        $response->headers->set('Content-Type', $contentType);
+        $response->headers->set('Content-Length', sprintf('%u', filesize($path))); // force file size to an unsigned
+        $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE);
+        $response->prepare($request);
+        return $response;
+    }
+
+
     private static function isModuleEnabledWithConf($module, $mod_config)
     {
         if (isset(self::$module_info[$module]['enabled'])) {
@@ -310,4 +520,21 @@ class Module
             $fn($data);
         }
     }
+
+
+    /**
+     * Handle a valid request that ends with a trailing slash.
+     *
+     * This method removes the trailing slash and redirects to the resulting URL.
+     *
+     * @param Request $request The request to process by this controller method.
+     *
+     * @return RedirectResponse A redirection to the URI specified in the request, but without the trailing slash.
+     */
+    public static function removeTrailingSlash(Request $request)
+    {
+        $pathInfo = $request->getPathInfo();
+        $url = str_replace($pathInfo, rtrim($pathInfo, ' /'), $request->getRequestUri());
+        return new RedirectResponse($url, 308);
+    }
 }
diff --git a/lib/SimpleSAML/Module/ControllerResolver.php b/lib/SimpleSAML/Module/ControllerResolver.php
new file mode 100644
index 0000000000000000000000000000000000000000..ba79109d100dde7da2701eecf4b6934744cf1292
--- /dev/null
+++ b/lib/SimpleSAML/Module/ControllerResolver.php
@@ -0,0 +1,196 @@
+<?php
+
+namespace SimpleSAML\Module;
+
+use SimpleSAML\Auth\AuthenticationFactory;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error\Exception;
+use SimpleSAML\Module;
+use SimpleSAML\Session;
+
+use Symfony\Component\Config\Exception\FileLocatorFileNotFoundException;
+use Symfony\Component\Config\FileLocator;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Reference;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface;
+use Symfony\Component\HttpKernel\Controller\ControllerResolver as SymfonyControllerResolver;
+use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
+use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory;
+use Symfony\Component\Routing\Exception\ResourceNotFoundException;
+use Symfony\Component\Routing\Loader\YamlFileLoader;
+use Symfony\Component\Routing\Matcher\UrlMatcher;
+use Symfony\Component\Routing\RequestContext;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * A class to resolve module controllers based on a given request.
+ *
+ * This class allows us to find a controller (a callable) that's configured for a given URL.
+ *
+ * @package SimpleSAML
+ */
+class ControllerResolver extends SymfonyControllerResolver implements ArgumentResolverInterface
+{
+
+    /** @var ArgumentMetadataFactory */
+    protected $argFactory;
+
+    /** @var ContainerBuilder */
+    protected $container;
+
+    /** @var string */
+    protected $module;
+
+    /** @var array */
+    protected $params;
+
+    /** @var RouteCollection */
+    protected $routes;
+
+
+    /**
+     * Build a module controller resolver.
+     *
+     * @param string $module The name of the module.
+     */
+    public function __construct($module)
+    {
+        parent::__construct();
+        $this->module = $module;
+
+        $loader = new YamlFileLoader(
+            new FileLocator(Module::getModuleDir($this->module))
+        );
+
+        $this->argFactory = new ArgumentMetadataFactory();
+        $this->container = new ContainerBuilder();
+        $this->container->autowire(AuthenticationFactory::class, AuthenticationFactory::class);
+
+        try {
+            $this->routes = $loader->load('routes.yaml');
+            $redirect = new Route(
+                '/{url}',
+                ['_controller' => '\SimpleSAML\Module::removeTrailingSlash'],
+                ['url' => '.*/$']
+            );
+            $this->routes->add('trailing-slash', $redirect);
+            $this->routes->addPrefix('/'.$this->module);
+        } catch (FileLocatorFileNotFoundException $e) {
+        }
+    }
+
+
+    /**
+     * Get the controller associated with a given URL, based on a request.
+     *
+     * This method searches for a 'routes.yaml' file in the root of the module, defining valid routes for the module
+     * and mapping them given controllers. It's input is a Request object with the request that we want to serve.
+     *
+     * @param Request $request The request we need to find a controller for.
+     *
+     * @return callable|false A controller (as a callable) that can handle the request, or false if we cannot find
+     * one suitable for the given request.
+     */
+    public function getController(Request $request)
+    {
+        if ($this->routes === null) {
+            return false;
+        }
+        $ctxt = new RequestContext();
+        $ctxt->fromRequest($request);
+
+        try {
+            $matcher = new UrlMatcher($this->routes, $ctxt);
+            $this->params = $matcher->match($ctxt->getPathInfo());
+            list($class, $method) = explode('::', $this->params['_controller']);
+            $this->container->register($class, $class)->setAutowired(true);
+            $this->container->compile();
+            return [$this->container->get($class), $method];
+        } catch (ResourceNotFoundException $e) {
+            // no route defined matching this request
+        }
+        return false;
+    }
+
+
+    /**
+     * Get the arguments that should be passed to a controller from a given request.
+     *
+     * When the signature of the controller includes arguments with type Request, the given request will be passed to
+     * those. Otherwise, they'll be matched by name. If no value is available for a given argument, the method will
+     * try to set a default value or null, if possible.
+     *
+     * @param Request $request The request that holds all the information needed by the controller.
+     * @param callable $controller A controller for the given request.
+     *
+     * @return array An array of arguments that should be passed to the controller, in order.
+     *
+     * @throws \SimpleSAML\Error\Exception If we don't find anything suitable for an argument in the controller's
+     * signature.
+     */
+    public function getArguments(Request $request, $controller)
+    {
+        $args = [];
+        $metadata = $this->argFactory->createArgumentMetadata($controller);
+
+        /** @var ArgumentMetadata $argMeta */
+        foreach ($metadata as $argMeta) {
+            if ($argMeta->getType() === 'Symfony\Component\HttpFoundation\Request') {
+                // add request argument
+                $args[] = $request;
+                continue;
+            }
+
+            $argName = $argMeta->getName();
+            if (array_key_exists($argName, $this->params)) {
+                // add argument by name
+                $args[] = $this->params[$argName];
+                continue;
+            }
+
+            // URL does not contain value for this argument
+            if ($argMeta->hasDefaultValue()) {
+                // it has a default value
+                $args[] = $argMeta->getDefaultValue();
+            }
+
+            // no default value
+            if ($argMeta->isNullable()) {
+                $args[] = null;
+            }
+
+            throw new Exception('Missing value for argument '.$argName.'. This is probably a bug.');
+        }
+
+        return $args;
+    }
+
+
+    /**
+     * Set the configuration to use by the controllers.
+     *
+     * @param \SimpleSAML\Configuration $config
+     */
+    public function setConfiguration(Configuration $config)
+    {
+        $this->container->set(Configuration::class, $config);
+        $this->container->register(Configuration::class)->setSynthetic(true)->setAutowired(true);
+    }
+
+
+    /**
+     * Set the session to use by the controllers.
+     *
+     * @param \SimpleSAML\Session $session
+     */
+    public function setSession(Session $session)
+    {
+        $this->container->set(Session::class, $session);
+        $this->container->register(Session::class)
+            ->setSynthetic(true)
+            ->setAutowired(true)
+            ->addMethodCall('setConfiguration', [new Reference(Configuration::class)]);
+    }
+}
diff --git a/lib/SimpleSAML/Session.php b/lib/SimpleSAML/Session.php
index 518f16294ade68b796b2141ac54186ac85fae9e3..62b0a52e3bf0d50a8c131bce0c4a582cc016c7d6 100644
--- a/lib/SimpleSAML/Session.php
+++ b/lib/SimpleSAML/Session.php
@@ -22,7 +22,7 @@ use SimpleSAML\Error;
  * @package SimpleSAMLphp
  */
 
-class Session implements \Serializable
+class Session implements \Serializable, Utils\ClearableState
 {
     /**
      * This is a timeout value for setData, which indicates that the data
@@ -48,6 +48,13 @@ class Session implements \Serializable
      */
     private static $instance = null;
 
+    /**
+     * The global configuration.
+     *
+     * @var \SimpleSAML\Configuration
+     */
+    private static $config;
+
     /**
      * The session ID of this session.
      *
@@ -131,6 +138,7 @@ class Session implements \Serializable
      */
     private $authData = [];
 
+
     /**
      * Private constructor that restricts instantiation to either getSessionFromRequest() for the current session or
      * getSession() for a specific one.
@@ -139,6 +147,8 @@ class Session implements \Serializable
      */
     private function __construct($transient = false)
     {
+        $this->setConfiguration(Configuration::getInstance());
+
         if (php_sapi_name() === 'cli' || defined('STDIN')) {
             $this->trackid = 'CL'.bin2hex(openssl_random_pseudo_bytes(4));
             Logger::setTrackId($this->trackid);
@@ -174,8 +184,7 @@ class Session implements \Serializable
             $this->markDirty();
 
             // initialize data for session check function if defined
-            $globalConfig = Configuration::getInstance();
-            $checkFunction = $globalConfig->getArray('session.check_function', null);
+            $checkFunction = self::$config->getArray('session.check_function', null);
             if (isset($checkFunction)) {
                 assert(is_callable($checkFunction));
                 call_user_func($checkFunction, $this, true);
@@ -183,6 +192,18 @@ class Session implements \Serializable
         }
     }
 
+
+    /**
+     * Set the configuration we should use.
+     *
+     * @param Configuration $config
+     */
+    public function setConfiguration(Configuration $config)
+    {
+        self::$config = $config;
+    }
+
+
     /**
      * Serialize this session object.
      *
@@ -192,8 +213,7 @@ class Session implements \Serializable
      */
     public function serialize()
     {
-        $serialized = serialize(get_object_vars($this));
-        return $serialized;
+        return serialize(get_object_vars($this));
     }
 
     /**
@@ -212,6 +232,7 @@ class Session implements \Serializable
                 $this->$k = $v;
             }
         }
+        self::$config = Configuration::getInstance();
 
         // look for any raw attributes and load them in the 'Attributes' array
         foreach ($this->authData as $authority => $parameters) {
@@ -542,8 +563,7 @@ class Session implements \Serializable
         assert(is_int($expire) || $expire === null);
 
         if ($expire === null) {
-            $globalConfig = Configuration::getInstance();
-            $expire = time() + $globalConfig->getInteger('session.rememberme.lifetime', 14 * 86400);
+            $expire = time() + self::$config->getInteger('session.rememberme.lifetime', 14 * 86400);
         }
         $this->rememberMeExpire = $expire;
 
@@ -581,12 +601,11 @@ class Session implements \Serializable
 
         $data['Authority'] = $authority;
 
-        $globalConfig = Configuration::getInstance();
         if (!isset($data['AuthnInstant'])) {
             $data['AuthnInstant'] = time();
         }
 
-        $maxSessionExpire = time() + $globalConfig->getInteger('session.duration', 8 * 60 * 60);
+        $maxSessionExpire = time() + self::$config->getInteger('session.duration', 8 * 60 * 60);
         if (!isset($data['Expire']) || $data['Expire'] > $maxSessionExpire) {
             // unset, or beyond our session lifetime. Clamp it to our maximum session lifetime
             $data['Expire'] = $maxSessionExpire;
@@ -621,13 +640,13 @@ class Session implements \Serializable
         $sessionHandler = SessionHandler::getSessionHandler();
 
         if (!$this->transient && (!empty($data['RememberMe']) || $this->rememberMeExpire) &&
-            $globalConfig->getBoolean('session.rememberme.enable', false)
+            self::$config->getBoolean('session.rememberme.enable', false)
         ) {
             $this->setRememberMeExpire();
         } else {
             try {
                 Utils\HTTP::setCookie(
-                    $globalConfig->getString('session.authtoken.cookiename', 'SimpleSAMLAuthToken'),
+                    self::$config->getString('session.authtoken.cookiename', 'SimpleSAMLAuthToken'),
                     $this->authToken,
                     $sessionHandler->getCookieParams()
                 );
@@ -755,9 +774,8 @@ class Session implements \Serializable
         $params = array_merge($sessionHandler->getCookieParams(), is_array($params) ? $params : []);
 
         if ($this->authToken !== null) {
-            $globalConfig = Configuration::getInstance();
             Utils\HTTP::setCookie(
-                $globalConfig->getString('session.authtoken.cookiename', 'SimpleSAMLAuthToken'),
+                self::$config->getString('session.authtoken.cookiename', 'SimpleSAMLAuthToken'),
                 $this->authToken,
                 $params
             );
@@ -778,8 +796,7 @@ class Session implements \Serializable
         $this->markDirty();
 
         if ($expire === null) {
-            $globalConfig = Configuration::getInstance();
-            $expire = time() + $globalConfig->getInteger('session.duration', 8 * 60 * 60);
+            $expire = time() + self::$config->getInteger('session.duration', 8 * 60 * 60);
         }
 
         $this->authData[$authority]['Expire'] = $expire;
@@ -859,9 +876,7 @@ class Session implements \Serializable
 
         if ($timeout === null) {
             // use the default timeout
-            $configuration = Configuration::getInstance();
-
-            $timeout = $configuration->getInteger('session.datastore.timeout', null);
+            $timeout = self::$config->getInteger('session.datastore.timeout', null);
             if ($timeout !== null) {
                 if ($timeout <= 0) {
                     throw new \Exception(
@@ -1134,4 +1149,15 @@ class Session implements \Serializable
         }
         return $authorities;
     }
+
+
+    /**
+     * Clear any configuration information cached
+     */
+    public static function clearInternalState()
+    {
+        self::$config = null;
+        self::$instance = null;
+        self::$sessions = null;
+    }
 }
diff --git a/lib/SimpleSAML/Store.php b/lib/SimpleSAML/Store.php
index d5be90656fd91737839453c198b00a9bfa864c4d..7f227fca2a37000354e6705b2b5622379b07df36 100644
--- a/lib/SimpleSAML/Store.php
+++ b/lib/SimpleSAML/Store.php
@@ -9,7 +9,7 @@ use SimpleSAML\Error\CriticalConfigurationError;
  *
  * @package SimpleSAMLphp
  */
-abstract class Store
+abstract class Store implements Utils\ClearableState
 {
     /**
      * Our singleton instance.
@@ -100,4 +100,13 @@ abstract class Store
      * @param string $key The key.
      */
     abstract public function delete($type, $key);
+
+
+    /**
+     * Clear any SSP specific state, such as SSP environmental variables or cached internals.
+     */
+    public static function clearInternalState()
+    {
+        self::$instance = null;
+    }
 }
diff --git a/lib/SimpleSAML/XHTML/Template.php b/lib/SimpleSAML/XHTML/Template.php
index 39bce25ebd516feace6cb381c946e3508e2dd522..903076ff67f701a2d66f6bcb691e8305fb1b69d5 100644
--- a/lib/SimpleSAML/XHTML/Template.php
+++ b/lib/SimpleSAML/XHTML/Template.php
@@ -11,8 +11,10 @@ namespace SimpleSAML\XHTML;
 
 use JaimePerez\TwigConfigurableI18n\Twig\Environment as Twig_Environment;
 use JaimePerez\TwigConfigurableI18n\Twig\Extensions\Extension\I18n as Twig_Extensions_Extension_I18n;
+use Symfony\Component\HttpFoundation\Response;
+use SimpleSAML\Locale\Localization;
 
-class Template
+class Template extends Response
 {
     /**
      * The data associated with this template, accessible within the template itself.
@@ -68,6 +70,14 @@ class Template
      */
     private $module;
 
+    /**
+     * Whether to use the new user interface or not.
+     *
+     * @var bool
+     */
+    private $useNewUI;
+
+
     /**
      * A template controller, if any.
      *
@@ -84,10 +94,10 @@ class Template
      * Whether we are using a non-default theme or not.
      *
      * If we are using a theme, this variable holds an array with two keys: "module" and "name", those being the name
-     * of the module and the name of the theme, respectively. If we are using the default theme, the variable defaults
-     * to false.
+     * of the module and the name of the theme, respectively. If we are using the default theme, the variable has
+     * the 'default' string in the "name" key, and 'null' in the "module" key.
      *
-     * @var bool|array
+     * @var array
      */
     private $theme;
 
@@ -117,6 +127,9 @@ class Template
         $this->translator = new \SimpleSAML\Locale\Translate($configuration, $defaultDictionary);
         $this->localization = new \SimpleSAML\Locale\Localization($configuration);
 
+        // check if we are supposed to use the new UI
+        $this->useNewUI = $this->configuration->getBoolean('usenewui', false);
+
         // check if we need to attach a theme controller
         $controller = $this->configuration->getString('theme.controller', false);
         if ($controller && class_exists($controller) &&
@@ -126,6 +139,18 @@ class Template
         }
 
         $this->twig = $this->setupTwig();
+        parent::__construct();
+    }
+
+
+    /**
+     * Get the normalized template name.
+     *
+     * @return string The name of the template to use.
+     */
+    public function getTemplateName()
+    {
+        return $this->normalizeTemplateName($this->template);
     }
 
 
@@ -148,7 +173,11 @@ class Template
         if ($tplpos) {
             $templateName = substr($templateName, 0, $tplpos);
         }
-        return $templateName.'.twig';
+
+        if ($this->useNewUI || $this->theme['module'] !== null) {
+            return $templateName.'.twig';
+        }
+        return $templateName;
     }
 
 
@@ -237,7 +266,7 @@ class Template
         // initialize some basic context
         $langParam = $this->configuration->getString('language.parameter.name', 'language');
         $twig->addGlobal('languageParameterName', $langParam);
-        $twig->addGlobal('localeBackend', $this->configuration->getString('language.i18n.backend', 'SimpleSAMLphp'));
+        $twig->addGlobal('localeBackend', $this->useNewUI ? Localization::GETTEXT_I18N_BACKEND : Localization::SSP_I18N_BACKEND);
         $twig->addGlobal('currentLanguage', $this->translator->getLanguage()->getLanguage());
         $twig->addGlobal('isRTL', false); // language RTL configuration
         if ($this->translator->getLanguage()->isLanguageRTL()) {
@@ -409,20 +438,50 @@ class Template
 
 
     /**
-     * Show the template to the user.
+     * Get the contents produced by this template.
+     *
+     * @return string The HTML rendered by this template, as a string.
+     * @throws \Exception if the template cannot be found.
      */
-    public function show()
+    protected function getContents()
     {
         if ($this->twig !== false) {
             $this->twigDefaultContext();
             if ($this->controller) {
                 $this->controller->display($this->data);
             }
-            echo $this->twig->render($this->twig_template, $this->data);
+            $content = $this->twig->render($this->twig_template, $this->data);
         } else {
-            $filename = $this->findTemplatePath($this->template);
-            require($filename);
+            $content = require($this->findTemplatePath($this->template));
         }
+        return $content;
+    }
+
+
+    /**
+     * Send this template as a response.
+     *
+     * @return Response This response.
+     * @throws \Exception if the template cannot be found.
+     */
+    public function send()
+    {
+        $this->content = $this->getContents();
+        return parent::send();
+    }
+
+
+    /**
+     * Show the template to the user.
+     *
+     * This method is a remnant of the old templating system, where templates where shown manually instead of
+     * returning a response.
+     *
+     * @deprecated Do not use this method, use send() instead.
+     */
+    public function show()
+    {
+        echo $this->getContents();
     }
 
 
diff --git a/modules/core/lib/Controller.php b/modules/core/lib/Controller.php
new file mode 100644
index 0000000000000000000000000000000000000000..8a4563f42df18c96261dab9a35344e64c8a01c7e
--- /dev/null
+++ b/modules/core/lib/Controller.php
@@ -0,0 +1,174 @@
+<?php
+
+namespace SimpleSAML\Module\core;
+
+use SimpleSAML\Error\Exception;
+use SimpleSAML\HTTP\RunnableResponse;
+
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Controller class for the core module.
+ *
+ * This class serves the different views available in the module.
+ *
+ * @package SimpleSAML\Module\core
+ */
+class Controller
+{
+
+    /** @var \SimpleSAML\Configuration */
+    protected $config;
+
+    /** @var \SimpleSAML\Auth\AuthenticationFactory */
+    protected $factory;
+
+    /** @var \SimpleSAML\Session */
+    protected $session;
+
+    /** @var array */
+    protected $sources;
+
+
+    /**
+     * Controller constructor.
+     *
+     * It initializes the global configuration and auth source configuration for the controllers implemented here.
+     *
+     * @param \SimpleSAML\Configuration              $config The configuration to use by the controllers.
+     * @param \SimpleSAML\Session                    $session The session to use by the controllers.
+     * @param \SimpleSAML\Auth\AuthenticationFactory $factory A factory to instantiate \SimpleSAML\Auth\Simple objects.
+     *
+     * @throws \Exception
+     */
+    public function __construct(
+        \SimpleSAML\Configuration $config,
+        \SimpleSAML\Session $session,
+        \SimpleSAML\Auth\AuthenticationFactory $factory
+    ) {
+        $this->config = $config;
+        $this->factory = $factory;
+        $this->sources = $config::getOptionalConfig('authsources.php')->toArray();
+        $this->session = $session;
+    }
+
+
+    /**
+     * Show account information for a given authentication source.
+     *
+     * @param string $as The identifier of the authentication source.
+     *
+     * @return \SimpleSAML\XHTML\Template|RedirectResponse An HTML template or a redirection if we are not
+     * authenticated.
+     *
+     * @throws \SimpleSAML\Error\Exception An exception in case the auth source specified is invalid.
+     */
+    public function account($as)
+    {
+        if (!array_key_exists($as, $this->sources)) {
+            throw new Exception('Invalid authentication source');
+        }
+
+        $auth = $this->factory->create($as);
+        if (!$auth->isAuthenticated()) {
+            // not authenticated, start auth with specified source
+            return new RedirectResponse(\SimpleSAML\Module::getModuleURL('core/login/'.urlencode($as)));
+        }
+
+        $attributes = $auth->getAttributes();
+
+        $t = new \SimpleSAML\XHTML\Template($this->config, 'auth_status.twig', 'attributes');
+        $t->data['header'] = '{status:header_saml20_sp}';
+        $t->data['attributes'] = $attributes;
+        $t->data['nameid'] = !is_null($auth->getAuthData('saml:sp:NameID'))
+            ? $auth->getAuthData('saml:sp:NameID')
+            : false;
+        $t->data['logouturl'] = \SimpleSAML\Module::getModuleURL('core/logout/'.urlencode($as));
+        $t->data['remaining'] = $this->session->getAuthData($as, 'Expire') - time();
+        $t->setStatusCode(200);
+
+        return $t;
+    }
+
+
+    /**
+     * Perform a login operation.
+     *
+     * This controller will either start a login operation (if that was requested, or if only one authentication
+     * source is available), or show a template allowing the user to choose which auth source to use.
+     *
+     * @param Request $request The request that lead to this login operation.
+     * @param string|null $as The name of the authentication source to use, if any. Optional.
+     *
+     * @return \SimpleSAML\XHTML\Template|\SimpleSAML\HTTP\RunnableResponse|RedirectResponse An HTML template, a
+     * redirect or a "runnable" response.
+     *
+     * @throws \SimpleSAML\Error\Exception
+     */
+    public function login(Request $request, $as = null)
+    {
+        //delete admin
+        if (isset($this->sources['admin'])) {
+            unset($this->sources['admin']);
+        }
+
+        if (count($this->sources) === 1 && $as === null) { // we only have one source available
+            $as = key($this->sources);
+        }
+
+        if ($as === null) { // no authentication source specified
+            $t = new \SimpleSAML\XHTML\Template($this->config, 'core:login.twig');
+            $t->data['loginurl'] = \SimpleSAML\Utils\Auth::getAdminLoginURL();
+            $t->data['sources'] = $this->sources;
+            return $t;
+        }
+
+        // auth source defined, check if valid
+        if (!array_key_exists($as, $this->sources)) {
+            throw new Exception('Invalid authentication source');
+        }
+
+        // at this point, we have a valid auth source selected, start auth
+        $auth = $this->factory->create($as);
+        $as = urlencode($as);
+
+        if ($request->get(\SimpleSAML\Auth\State::EXCEPTION_PARAM, false) !== false) {
+            // This is just a simple example of an error
+
+            $state = \SimpleSAML\Auth\State::loadExceptionState();
+            assert(array_key_exists(\SimpleSAML\Auth\State::EXCEPTION_DATA, $state));
+            $e = $state[\SimpleSAML\Auth\State::EXCEPTION_DATA];
+
+            throw $e;
+        }
+
+        if ($auth->isAuthenticated()) {
+            return new RedirectResponse(\SimpleSAML\Module::getModuleURL('core/account/'.$as));
+        }
+
+        // we're not logged in, start auth
+        $url = \SimpleSAML\Module::getModuleURL('core/login/'.$as);
+        $params = array(
+            'ErrorURL' => $url,
+            'ReturnTo' => $url,
+        );
+        return new RunnableResponse([$auth, 'login'], [$params]);
+    }
+
+
+    /**
+     * Log the user out of a given authentication source.
+     *
+     * @param string $as The name of the auth source.
+     *
+     * @return \SimpleSAML\HTTP\RunnableResponse A runnable response which will actually perform logout.
+     *
+     * @throws \SimpleSAML\Error\CriticalConfigurationError
+     */
+    public function logout($as)
+    {
+        $auth = new \SimpleSAML\Auth\Simple($as);
+        return new RunnableResponse([$auth, 'logout'], [$this->config->getBasePath().'logout.php']);
+    }
+}
diff --git a/modules/core/routes.yaml b/modules/core/routes.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..80916d2ab51c7a39b650b5c841983f0ba4a629f2
--- /dev/null
+++ b/modules/core/routes.yaml
@@ -0,0 +1,9 @@
+core-login:
+    path:       /login/{as}
+    defaults:   { _controller: 'SimpleSAML\Module\core\Controller::login', as: null }
+core-account:
+    path:       /account/{as}
+    defaults:   { _controller: 'SimpleSAML\Module\core\Controller::account' }
+core-logout:
+    path:       /logout/{as}
+    defaults:   { _controller: 'SimpleSAML\Module\core\Controller::logout' }
diff --git a/modules/core/templates/login.twig b/modules/core/templates/login.twig
index e1b342cc0a9ba9e71324930c7ff732f2e69f860e..03297b97192abad0c08f0d111da7be3ec87d9b54 100644
--- a/modules/core/templates/login.twig
+++ b/modules/core/templates/login.twig
@@ -12,7 +12,11 @@
     <div class="pure-menu custom-restricted-width">
         <ul class="pure-menu-list auth_methods">
             {% for id, config in sources -%}
-                <li class="pure-menu-item"><a href="?as={{ id|url_encode }}" class="pure-menu-link">{{ config.name|translateFromArray|default(id) }}</a></li>
+                <li class="pure-menu-item">
+                    <a href="{{ baseurlpath }}/module.php/core/login/{{ id|url_encode }}" class="pure-menu-link">
+                      {{ config.name|translateFromArray|default(id) }}
+                    </a>
+                </li>
             {% endfor -%}
         </ul>
     </div>
diff --git a/modules/core/www/frontpage_auth.php b/modules/core/www/frontpage_auth.php
index 081151788e230f0fccedf24d7e9f1cbb0b51b9a4..a3bf112e150efee435323163208942432d79ee9c 100644
--- a/modules/core/www/frontpage_auth.php
+++ b/modules/core/www/frontpage_auth.php
@@ -19,7 +19,7 @@ $links_auth = [];
 $links_federation = [];
 
 $links_auth[] = [
-    'href' => 'login.php',
+    'href' => 'authenticate.php',
     'text' => '{core:frontpage:authtest}',
 ];
 
diff --git a/modules/core/www/login.php b/modules/core/www/login.php
deleted file mode 100644
index 53d57ca44976bbc805c2df889255182449eabe98..0000000000000000000000000000000000000000
--- a/modules/core/www/login.php
+++ /dev/null
@@ -1,63 +0,0 @@
-<?php
-
-$config = \SimpleSAML\Configuration::getInstance();
-$sources = \SimpleSAML\Configuration::getOptionalConfig('authsources.php')->toArray();
-
-//delete admin
-if (isset($sources['admin'])) {
-    unset($sources['admin']);
-}
-
-//if only 1 auth
-if (count($sources) == 1) {
-    $_REQUEST['as'] = key($sources);
-}
-
-if (!array_key_exists('as', $_REQUEST)) {
-    $t = new \SimpleSAML\XHTML\Template($config, 'core:login.twig');
-
-    $t->data['loginurl'] = \SimpleSAML\Utils\Auth::getAdminLoginURL();
-    $t->data['sources'] = $sources;
-    $t->show();
-    exit();
-}
-
-$asId = (string) $_REQUEST['as'];
-$as = new \SimpleSAML\Auth\Simple($asId);
-
-if (array_key_exists('logout', $_REQUEST)) {
-    $as->logout($config->getBasePath().'logout.php');
-}
-
-if (array_key_exists(\SimpleSAML\Auth\State::EXCEPTION_PARAM, $_REQUEST)) {
-    // This is just a simple example of an error
-
-    $state = \SimpleSAML\Auth\State::loadExceptionState();
-    assert(array_key_exists(\SimpleSAML\Auth\State::EXCEPTION_DATA, $state));
-    $e = $state[\SimpleSAML\Auth\State::EXCEPTION_DATA];
-
-    throw $e;
-}
-
-if (!$as->isAuthenticated()) {
-    $url = \SimpleSAML\Module::getModuleURL('core/login.php', ['as' => $asId]);
-    $params = [
-        'ErrorURL' => $url,
-        'ReturnTo' => $url,
-    ];
-    $as->login($params);
-}
-
-$attributes = $as->getAttributes();
-$session = \SimpleSAML\Session::getSessionFromRequest();
-
-$t = new \SimpleSAML\XHTML\Template($config, 'auth_status.twig', 'attributes');
-
-
-$t->data['header'] = '{status:header_saml20_sp}';
-$t->data['attributes'] = $attributes;
-$t->data['nameid'] = !is_null($as->getAuthData('saml:sp:NameID')) ? $as->getAuthData('saml:sp:NameID') : false;
-$t->data['logouturl'] = \SimpleSAML\Utils\HTTP::getSelfURLNoQuery().'?as='.urlencode($asId).'&logout';
-$t->data['remaining'] = $session->getAuthData($asId, 'Expire') - time();
-
-$t->show();
diff --git a/templates/sandbox.twig b/templates/sandbox.twig
index 316c855cfba98e398a8237ed4dc77567505efdf5..1ff8134fd7e0a2d08a1b1be18f7623549cce2ef3 100644
--- a/templates/sandbox.twig
+++ b/templates/sandbox.twig
@@ -15,17 +15,6 @@
     </p>
     <h2>Localization</h2>
     {% set variable = 'Hello, Untranslated World!' %}
-    <p>SimpleSAMLphp lets you choose which translation backend to choose, thanks to the
-        <code>language.i18n.backend</code> configuration option. Two possible values are supported there:
-    </p>
-    <ul>
-        <li><code>SimpleSAMLphp</code>: to keep using the old SimpleSAMLphp translation system. This is the
-            default, and will disappear as an option in SimpleSAMLphp 2.0.</li>
-        <li><code>gettext/gettext</code>: to use a <em>gettext</em> implementation written entirely in PHP, allowing
-            you to use any locale, no matter if they are installed on the system or not.</li>
-    </ul>
-    <p>Note that <code>gettext/gettext</code> <strong>will become the default</strong> in SimpleSAMLphp 2.0.
-        Currently, you are using the following backend: <code>{{ localeBackend }}</code>.</p>
     <p>This page is written in english only, but the examples used here are translated to several languages. The current
         language is <strong>{{ currentLanguage }}</strong>. Change to other languages to see the examples change.</p>
     <h4>Usage examples</h4>
diff --git a/tests/Utils/StateClearer.php b/tests/Utils/StateClearer.php
index 880c66bdf6332969503f23ec23743d7d0e6868b7..9472ef1993e6b18f675d8cd72df420b99b608edd 100644
--- a/tests/Utils/StateClearer.php
+++ b/tests/Utils/StateClearer.php
@@ -18,7 +18,7 @@ class StateClearer
      * Class that implement \SimpleSAML\Utils\ClearableState and should have clearInternalState called between tests
      * @var array
      */
-    private $clearableState = ['SimpleSAML\Configuration', 'SimpleSAML\Metadata\MetaDataStorageHandler'];
+    private $clearableState = ['SimpleSAML\Configuration', 'SimpleSAML\Metadata\MetaDataStorageHandler', 'SimpleSAML\Store', 'SimpleSAML\Session'];
 
     /**
      * Environmental variables to unset
diff --git a/tests/lib/SimpleSAML/Locale/LocalizationTest.php b/tests/lib/SimpleSAML/Locale/LocalizationTest.php
index cc58f1ebe66632efcd6ffac4b9d4ce21e5391c8a..ceeb823fba12480b83cf812efed375eee9fc3b48 100644
--- a/tests/lib/SimpleSAML/Locale/LocalizationTest.php
+++ b/tests/lib/SimpleSAML/Locale/LocalizationTest.php
@@ -22,7 +22,7 @@ class LocalizationTest extends TestCase
     {
         // The constructor should activate the default domain
         $c = Configuration::loadFromArray(
-            ['language.i18n.backend' => 'SimpleSAMLphp']
+            ['usenewui' => false]
         );
         $l = new Localization($c);
         $this->assertTrue($l->isI18NBackendDefault());
@@ -35,7 +35,7 @@ class LocalizationTest extends TestCase
     public function testAddDomain()
     {
         $c = Configuration::loadFromArray(
-            ['language.i18n.backend' => 'gettext/gettext']
+            ['usenewui' => true]
         );
         $l = new Localization($c);
         $newDomain = 'test';
diff --git a/tests/modules/core/lib/ControllerTest.php b/tests/modules/core/lib/ControllerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9f2edea91b9409784e2b0f97e2161d634d32fca5
--- /dev/null
+++ b/tests/modules/core/lib/ControllerTest.php
@@ -0,0 +1,244 @@
+<?php
+
+namespace SimpleSAML\Test\Module\core;
+
+use SimpleSAML\Auth\Simple;
+use SimpleSAML\Auth\AuthenticationFactory;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error\Exception;
+use SimpleSAML\HTTP\RunnableResponse;
+use SimpleSAML\Locale\Localization;
+use SimpleSAML\Module\core\Controller;
+use SimpleSAML\Session;
+use SimpleSAML\Test\Utils\ClearStateTestCase;
+use SimpleSAML\XHTML\Template;
+
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Set of tests for the controllers in the "core" module.
+ *
+ * For now, this test extends ClearStateTestCase so that it doesn't interfere with other tests. Once every class has
+ * been made PSR-7-aware, that won't be necessary any longer.
+ *
+ * @package SimpleSAML\Test
+ */
+class ControllerTest extends ClearStateTestCase
+{
+
+    /** @var array */
+    protected $authSources;
+
+    /** @var \SimpleSAML\Configuration */
+    protected $config;
+
+    /** @var \SimpleSAML\Configuration[] */
+    protected $loadedConfigs;
+
+    /** @var \SimpleSAML\HTTP\Router */
+    protected $router;
+
+
+    /**
+     * Set up for each test.
+     */
+    protected function setUp()
+    {
+        parent::setUp();
+        $this->authSources = [
+            'admin' => [
+                'core:adminPassword'
+            ],
+            'example-userpass' => [
+                'exampleauth:UserPass',
+                'username:password' => [
+                   'uid' => ['test']
+                ]
+            ]
+        ];
+        $this->config = Configuration::loadFromArray(
+            [
+                'baseurlpath' => 'https://example.org/simplesaml',
+                'module.enable' => ['exampleauth' => true],
+                'usenewui' => true,
+            ],
+            '[ARRAY]',
+            'simplesaml'
+        );
+        Configuration::setPreLoadedConfig($this->config, 'config.php');
+    }
+
+
+    /**
+     * Test that authentication is started immediately if we hit the login endpoint and there's only one non-admin
+     * source configured.
+     */
+    public function testAutomaticLoginWhenOnlyOneSource()
+    {
+        $asConfig = Configuration::loadFromArray($this->authSources);
+        Configuration::setPreLoadedConfig($asConfig, 'authsources.php');
+        $request = new Request();
+        $session = Session::getSessionFromRequest();
+        $factory = new AuthenticationFactory($this->config, $session);
+        $c = new Controller($this->config, $session, $factory);
+        $response = $c->login($request);
+        $this->assertInstanceOf(RunnableResponse::class, $response);
+        list($object, $method) = $response->getCallable();
+        $this->assertInstanceOf(Simple::class, $object);
+        $this->assertEquals('login', $method);
+        $arguments = $response->getArguments();
+        $this->assertArrayHasKey('ErrorURL', $arguments[0]);
+        $this->assertArrayHasKey('ReturnTo', $arguments[0]);
+    }
+
+
+    /**
+     * Test that the user can choose what auth source to use when there are multiple defined (admin excluded).
+     */
+    public function testMultipleAuthSources()
+    {
+        $_SERVER['REQUEST_URI'] = '/';
+        $asConfig = Configuration::loadFromArray(
+            array_merge(
+                $this->authSources,
+                [
+                    'example-static' => [
+                        'exampleauth:StaticSource',
+                        'uid' => ['test']
+                    ]
+                ]
+            )
+        );
+        Configuration::setPreLoadedConfig($asConfig, 'authsources.php');
+        $request = new Request();
+        $session = Session::getSessionFromRequest();
+        $factory = new AuthenticationFactory($this->config, $session);
+        $c = new Controller($this->config, $session, $factory);
+        $response = $c->login($request);
+        $this->assertInstanceOf(Template::class, $response);
+        $this->assertEquals('core:login.twig', $response->getTemplateName());
+        $this->assertArrayHasKey('sources', $response->data);
+        $this->assertArrayHasKey('example-userpass', $response->data['sources']);
+        $this->assertArrayHasKey('example-static', $response->data['sources']);
+    }
+
+
+    /**
+     * Test that specifying an invalid auth source while trying to login raises an exception.
+     */
+    public function testLoginWithInvalidAuthSource()
+    {
+        $asConfig = Configuration::loadFromArray($this->authSources);
+        Configuration::setPreLoadedConfig($asConfig, 'authsources.php');
+        $request = new Request();
+        $session = Session::getSessionFromRequest();
+        $factory = new AuthenticationFactory($this->config, $session);
+        $c = new Controller($this->config, $session, $factory);
+        $this->setExpectedException(Exception::class);
+        $c->login($request, 'invalid-auth-source');
+    }
+
+
+    /**
+     * Test that we get redirected to /account/authsource when accessing the login endpoint while being already
+     * authenticated.
+     */
+    public function testLoginWhenAlreadyAuthenticated()
+    {
+        $asConfig = Configuration::loadFromArray($this->authSources);
+        Configuration::setPreLoadedConfig($asConfig, 'authsources.php');
+        $request = new Request();
+        $session = Session::getSessionFromRequest();
+        $session->setConfiguration($this->config);
+        $class = new \ReflectionClass($session);
+        $authData = $class->getProperty('authData');
+        $authData->setAccessible(true);
+        $authData->setValue($session, [
+            'example-userpass' => [
+                'exampleauth:UserPass',
+                'Attributes' => ['uid' => ['test']],
+                'Authority' => 'example-userpass',
+                'AuthnInstant' => time(),
+                'Expire' => time() + 8 * 60* 60
+            ]
+        ]);
+        $factory = new AuthenticationFactory($this->config, $session);
+        $c = new Controller($this->config, $session, $factory);
+        $response = $c->login($request);
+        $this->assertInstanceOf(RedirectResponse::class, $response);
+        $this->assertEquals(
+            'https://example.org/simplesaml/module.php/core/account/example-userpass',
+            $response->getTargetUrl()
+        );
+    }
+
+
+    /**
+     * Test that triggering the logout controller actually proceeds to log out from the specified source.
+     */
+    public function testLogout()
+    {
+        $asConfig = Configuration::loadFromArray($this->authSources);
+        Configuration::setPreLoadedConfig($asConfig, 'authsources.php');
+        $session = Session::getSessionFromRequest();
+        $factory = new AuthenticationFactory($this->config, $session);
+        $c = new Controller($this->config, $session, $factory);
+        $response = $c->logout('example-userpass');
+        $this->assertInstanceOf(RunnableResponse::class, $response);
+        list($object, $method) = $response->getCallable();
+        $this->assertInstanceOf(Simple::class, $object);
+        $this->assertEquals('logout', $method);
+        $this->assertEquals('/simplesaml/logout.php', $response->getArguments()[0]);
+    }
+
+
+    /**
+     * Test that accessing the "account" endpoint without being authenticated gets you redirected to the "login"
+     * endpoint.
+     */
+    public function testNotAuthenticated()
+    {
+        $asConfig = Configuration::loadFromArray($this->authSources);
+        Configuration::setPreLoadedConfig($asConfig, 'authsources.php');
+        $session = Session::getSessionFromRequest();
+        $factory = new AuthenticationFactory($this->config, $session);
+        $c = new Controller($this->config, $session, $factory);
+        /** @var RedirectResponse $response */
+        $response = $c->account('example-userpass');
+        $this->assertInstanceOf(RedirectResponse::class, $response);
+        $this->assertEquals(
+            'https://example.org/simplesaml/module.php/core/login/example-userpass',
+            $response->getTargetUrl()
+        );
+    }
+
+
+    /**
+     * Test that we are presented with a regular page if we are authenticated and try to access the "account" endpoint.
+     */
+    public function testAuthenticated()
+    {
+        $asConfig = Configuration::loadFromArray($this->authSources);
+        Configuration::setPreLoadedConfig($asConfig, 'authsources.php');
+        $session = Session::getSessionFromRequest();
+        $class = new \ReflectionClass($session);
+        $authData = $class->getProperty('authData');
+        $authData->setAccessible(true);
+        $authData->setValue($session, [
+            'example-userpass' => [
+                'exampleauth:UserPass',
+                'Attributes' => ['uid' => ['test']],
+                'Authority' => 'example-userpass',
+                'AuthnInstant' => time(),
+                'Expire' => time() + 8 * 60* 60
+            ]
+        ]);
+        $factory = new AuthenticationFactory($this->config, $session);
+        $c = new Controller($this->config, $session, $factory);
+        /** @var \SimpleSAML\XHTML\Template $response */
+        $response = $c->account('example-userpass');
+        $this->assertInstanceOf(Template::class, $response);
+        $this->assertEquals('auth_status.twig', $response->getTemplateName());
+    }
+}
diff --git a/tests/www/TemplateTest.php b/tests/www/TemplateTest.php
index 06dfe10808c2ea612f0050eeb3c20a915fd99f72..2e9e3a4f4fc2d06e7e241136ba2e170dda5695f6 100644
--- a/tests/www/TemplateTest.php
+++ b/tests/www/TemplateTest.php
@@ -19,7 +19,7 @@ class TemplateTest extends TestCase
     public function testSyntax()
     {
         $config = Configuration::loadFromArray([
-            'language.i18n.backend' => 'gettext/gettext',
+            'usenewui' => true,
             'module.enable' => array_fill_keys(Module::getModules(), true),
         ]);
         Configuration::setPreLoadedConfig($config);
diff --git a/www/index.php b/www/index.php
index b5b456c7a8c3828829d28652896aae3aac3a1595..d080b8af96d8e7980c718fd48c73e72ef9c529f5 100644
--- a/www/index.php
+++ b/www/index.php
@@ -5,7 +5,7 @@ require_once('_include.php');
 $config = \SimpleSAML\Configuration::getInstance();
 
 if ($config->getBoolean('usenewui', false)) {
-    \SimpleSAML\Utils\HTTP::redirectTrustedURL(SimpleSAML\Module::getModuleURL('core/login.php'));
+    \SimpleSAML\Utils\HTTP::redirectTrustedURL(SimpleSAML\Module::getModuleURL('core/login'));
 }
 
 \SimpleSAML\Utils\HTTP::redirectTrustedURL(SimpleSAML\Module::getModuleURL('core/frontpage_welcome.php'));
diff --git a/www/module.php b/www/module.php
index caac395fb2ae60a16b314ffcdba0f5810e36c86d..83ee0ff382ea24cf6e1ec116e7d6b298d21a1eb0 100644
--- a/www/module.php
+++ b/www/module.php
@@ -1,171 +1,9 @@
 <?php
 
 /**
- * Handler for module requests.
- *
  * This web page receives requests for web-pages hosted by modules, and directs them to
- * the RequestHandler in the module.
- *
- * @author Olav Morken, UNINETT AS.
- * @package SimpleSAMLphp
+ * the process() handler in the Module class.
  */
-
 require_once('_include.php');
 
-// index pages - file names to attempt when accessing directories
-$indexFiles = ['index.php', 'index.html', 'index.htm', 'index.txt'];
-
-// MIME types - key is file extension, value is MIME type
-$mimeTypes = [
-    'bmp'   => 'image/x-ms-bmp',
-    'css'   => 'text/css',
-    'gif'   => 'image/gif',
-    'htm'   => 'text/html',
-    'html'  => 'text/html',
-    'shtml' => 'text/html',
-    'ico'   => 'image/vnd.microsoft.icon',
-    'jpe'   => 'image/jpeg',
-    'jpeg'  => 'image/jpeg',
-    'jpg'   => 'image/jpeg',
-    'js'    => 'text/javascript',
-    'pdf'   => 'application/pdf',
-    'png'   => 'image/png',
-    'svg'   => 'image/svg+xml',
-    'svgz'  => 'image/svg+xml',
-    'swf'   => 'application/x-shockwave-flash',
-    'swfl'  => 'application/x-shockwave-flash',
-    'txt'   => 'text/plain',
-    'xht'   => 'application/xhtml+xml',
-    'xhtml' => 'application/xhtml+xml',
-];
-
-if (empty($_SERVER['PATH_INFO'])) {
-    throw new \SimpleSAML\Error\NotFound('No PATH_INFO to module.php');
-}
-
-$url = $_SERVER['PATH_INFO'];
-assert(substr($url, 0, 1) === '/');
-
-/* clear the PATH_INFO option, so that a script can detect whether it is called with anything following the
- *'.php'-ending.
- */
-unset($_SERVER['PATH_INFO']);
-
-$modEnd = strpos($url, '/', 1);
-if ($modEnd === false) {
-    // the path must always be on the form /module/
-    throw new \SimpleSAML\Error\NotFound('The URL must at least contain a module name followed by a slash.');
-}
-
-$module = substr($url, 1, $modEnd - 1);
-$url = substr($url, $modEnd + 1);
-if ($url === false) {
-    $url = '';
-}
-
-if (!SimpleSAML\Module::isModuleEnabled($module)) {
-    throw new \SimpleSAML\Error\NotFound('The module \''.$module.'\' was either not found, or wasn\'t enabled.');
-}
-
-/* Make sure that the request isn't suspicious (contains references to current directory or parent directory or
- * anything like that. Searching for './' in the URL will detect both '../' and './'. Searching for '\' will detect
- * attempts to use Windows-style paths.
- */
-if (strpos($url, '\\') !== false) {
-    throw new SimpleSAML\Error\BadRequest('Requested URL contained a backslash.');
-} elseif (strpos($url, './') !== false) {
-    throw new \SimpleSAML\Error\BadRequest('Requested URL contained \'./\'.');
-}
-
-$moduleDir = SimpleSAML\Module::getModuleDir($module).'/www/';
-
-// check for '.php/' in the path, the presence of which indicates that another php-script should handle the request
-for ($phpPos = strpos($url, '.php/'); $phpPos !== false; $phpPos = strpos($url, '.php/', $phpPos + 1)) {
-    $newURL = substr($url, 0, $phpPos + 4);
-    $param = substr($url, $phpPos + 4);
-
-    if (is_file($moduleDir.$newURL)) {
-        /* $newPath points to a normal file. Point execution to that file, and
-         * save the remainder of the path in PATH_INFO.
-         */
-        $url = $newURL;
-        $_SERVER['PATH_INFO'] = $param;
-        break;
-    }
-}
-
-$path = $moduleDir.$url;
-
-if ($path[strlen($path) - 1] === '/') {
-    // path ends with a slash - directory reference. Attempt to find index file in directory
-    foreach ($indexFiles as $if) {
-        if (file_exists($path.$if)) {
-            $path .= $if;
-            break;
-        }
-    }
-}
-
-if (is_dir($path)) {
-    /* Path is a directory - maybe no index file was found in the previous step, or maybe the path didn't end with
-     * a slash. Either way, we don't do directory listings.
-     */
-    throw new \SimpleSAML\Error\NotFound('Directory listing not available.');
-}
-
-if (!file_exists($path)) {
-    // file not found
-    SimpleSAML\Logger::info('Could not find file \''.$path.'\'.');
-    throw new \SimpleSAML\Error\NotFound('The URL wasn\'t found in the module.');
-}
-
-if (preg_match('#\.php$#D', $path)) {
-    // PHP file - attempt to run it
-
-    /* In some environments, $_SERVER['SCRIPT_NAME'] is already set with $_SERVER['PATH_INFO']. Check for that case,
-     * and append script name only if necessary.
-     *
-     * Contributed by Travis Hegner.
-     */
-    $script = "/$module/$url";
-    if (stripos($_SERVER['SCRIPT_NAME'], $script) === false) {
-        $_SERVER['SCRIPT_NAME'] .= '/'.$module.'/'.$url;
-    }
-
-    require($path);
-    exit();
-}
-
-// some other file type - attempt to serve it
-
-// find MIME type for file, based on extension
-$contentType = null;
-if (preg_match('#\.([^/\.]+)$#D', $path, $type)) {
-    $type = strtolower($type[1]);
-    if (array_key_exists($type, $mimeTypes)) {
-        $contentType = $mimeTypes[$type];
-    }
-}
-
-if ($contentType === null) {
-    /* We were unable to determine the MIME type from the file extension. Fall back to mime_content_type (if it
-     * exists).
-     */
-    if (function_exists('mime_content_type')) {
-        $contentType = mime_content_type($path);
-    } else {
-        // mime_content_type doesn't exist. Return a default MIME type
-        SimpleSAML\Logger::warning('Unable to determine mime content type of file: '.$path);
-        $contentType = 'application/octet-stream';
-    }
-}
-
-$contentLength = sprintf('%u', filesize($path)); // force filesize to an unsigned number
-
-header('Content-Type: '.$contentType);
-header('Content-Length: '.$contentLength);
-header('Cache-Control: public,max-age=86400');
-header('Expires: '.gmdate('D, j M Y H:i:s \G\M\T', time() + 10 * 60));
-header('Last-Modified: '.gmdate('D, j M Y H:i:s \G\M\T', filemtime($path)));
-
-readfile($path);
+\SimpleSAML\Module::process()->send();