diff --git a/modules/core/lib/Controller/ErrorReport.php b/modules/core/lib/Controller/ErrorReport.php
new file mode 100644
index 0000000000000000000000000000000000000000..1afb833c54ceaa24d4362761be640f4c66108323
--- /dev/null
+++ b/modules/core/lib/Controller/ErrorReport.php
@@ -0,0 +1,110 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\core\Controller;
+
+use Exception;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error;
+use SimpleSAML\HTTP\RunnableResponse;
+use SimpleSAML\Logger;
+use SimpleSAML\Utils;
+use SimpleSAML\XHTML\Template;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+use function dirname;
+use function php_uname;
+use function var_export;
+
+/**
+ * Controller class for the core module.
+ *
+ * This class serves the different views available in the module.
+ *
+ * @package SimpleSAML\Module\core
+ */
+class ErrorReport
+{
+    /** @var \SimpleSAML\Configuration */
+    protected Configuration $config;
+
+
+    /**
+     * Controller constructor.
+     *
+     * It initializes the global configuration for the controllers implemented here.
+     *
+     * @param \SimpleSAML\Configuration $config The configuration to use by the controllers.
+     */
+    public function __construct(
+        Configuration $config
+    ) {
+        $this->config = $config;
+    }
+
+
+    /**
+     * @param \Symfony\Component\HttpFoundation\Request $request
+     * @return \SimpleSAML\XHTML\Template|\SimpleSAML\HTTP\RunnableResponse
+     */
+    public function main(Request $request): Response
+    {
+        // this page will redirect to itself after processing a POST request and sending the email
+        if ($request->server->get('REQUEST_METHOD') !== 'POST') {
+            // the message has been sent. Show error report page
+
+            return new Template($this->config, 'core:errorreport.twig');
+        }
+
+        $reportId = $request->request->get('reportId');
+        $email = $request->request->get('email');
+        $text = $request->request->get('text');
+
+        if (!preg_match('/^[0-9a-f]{8}$/', $reportId)) {
+            throw new Error\Exception('Invalid reportID');
+        }
+
+        $data = null;
+        try {
+            $data = $this->session->getData('core:errorreport', $reportId);
+        } catch (Exception $e) {
+            Logger::error('Error loading error report data: ' . var_export($e->getMessage(), true));
+        }
+
+        if ($data === null) {
+            $data = [
+                'exceptionMsg'   => 'not set',
+                'exceptionTrace' => 'not set',
+                'trackId'        => 'not set',
+                'url'            => 'not set',
+                'referer'        => 'not set',
+            ];
+
+            if (isset($session)) {
+                $data['trackId'] = $session->getTrackID();
+            }
+        }
+
+        $data['reportId'] = $reportId;
+        $data['version'] = $this->config->getVersion();
+        $data['hostname'] = php_uname('n');
+        $data['directory'] = dirname(dirname(__FILE__));
+
+        if ($this->config->getOptionalBoolean('errorreporting', true)) {
+            $mail = new Utils\EMail('SimpleSAMLphp error report from ' . $email);
+            $mail->setData($data);
+            if (filter_var($email, FILTER_VALIDATE_EMAIL, FILTER_REQUIRE_SCALAR)) {
+                $mail->addReplyTo($email);
+            }
+            $mail->setText($text);
+            $mail->send();
+            Logger::error('Report with id ' . $reportId . ' sent');
+        }
+
+        // redirect the user back to this page to clear the POST request
+        $httpUtils = new Utils\HTTP();
+        return new RunnableResponse([$httpUtils, 'redirectTrustedURL'], [$httpUtils->getSelfURLNoQuery()]);
+    }
+}
diff --git a/modules/core/routing/routes/routes.yml b/modules/core/routing/routes/routes.yml
index 7ed2a87b61145345a3a0a79e26f1996b322c9023..a41de34a1d17e4ef698d702824baaad930328708 100644
--- a/modules/core/routing/routes/routes.yml
+++ b/modules/core/routing/routes/routes.yml
@@ -49,3 +49,6 @@ core-logout-iframe-done:
 core-logout-iframe-post:
     path:       /logout-iframe-post
     defaults:   { _controller: 'SimpleSAML\Module\core\Controller\Logout::logoutIframePost' }
+core-error-report:
+    path:       /errorReport
+    defaults:   { _controller: '\SimpleSAML\Module\core\Controller\ErrorReport::main' }
diff --git a/templates/errorreport.twig b/modules/core/templates/errorreport.twig
similarity index 100%
rename from templates/errorreport.twig
rename to modules/core/templates/errorreport.twig
diff --git a/tests/modules/core/lib/Controller/ErrorReportTest.php b/tests/modules/core/lib/Controller/ErrorReportTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9c893f0b82d3f8a7a3a41dcbe0b481d459fdd48b
--- /dev/null
+++ b/tests/modules/core/lib/Controller/ErrorReportTest.php
@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\core\Controller;
+
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error;
+use SimpleSAML\HTTP\RunnableResponse;
+use SimpleSAML\Module\core\Controller;
+use SimpleSAML\XHTML\Template;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Set of tests for the controllers in the "core" module.
+ *
+ * @covers \SimpleSAML\Module\core\Controller\ErrorReport
+ * @package SimpleSAML\Test
+ */
+class ErrorReportTest extends TestCase
+{
+    /** @var \SimpleSAML\Configuration */
+    protected Configuration $config;
+
+
+    /**
+     * Set up for each test.
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->config = Configuration::loadFromArray(
+            [
+                'errorreporting' => false,
+                'module.enable' => ['core' => true],
+            ],
+            '[ARRAY]',
+            'simplesaml'
+        );
+
+        Configuration::setPreLoadedConfig($this->config, 'config.php');
+    }
+
+
+    /**
+     * Test that we are presented with an 'error was reported' page
+     */
+    public function testErrorReportSent(): void
+    {
+        $request = Request::create(
+            '/errorReport',
+            'GET',
+        );
+
+        $c = new Controller\ErrorReport($this->config);
+
+        $response = $c->main($request);
+
+        $this->assertInstanceOf(Template::class, $response);
+        $this->assertEquals('core:errorreport.twig', $response->getTemplateName());
+    }
+
+
+    /**
+     * Test that we are presented with an 'error was reported' page
+     */
+    public function testErrorReportIncorrectReportID(): void
+    {
+        $request = Request::create(
+            '/errorReport',
+            'POST',
+            ['reportId' => 'abc123'],
+        );
+
+        $c = new Controller\ErrorReport($this->config);
+
+        $this->expectException(Error\Exception::class);
+        $this->expectExceptionMessage('Invalid reportID');
+
+        $c->main($request);
+    }
+
+
+    /**
+     * Test that we are presented with an 'error was reported' page
+     */
+    public function testErrorReport(): void
+    {
+        $request = Request::create(
+            '/errorReport',
+            'POST',
+            [
+                'reportId' => 'abcd1234',
+                'email' => 'phpunit@example.org',
+                'text' => 'phpunit',
+            ],
+        );
+
+        $c = new Controller\ErrorReport($this->config);
+
+        $response = $c->main($request);
+
+        $this->assertInstanceOf(RunnableResponse::class, $response);
+    }
+}
diff --git a/www/errorreport.php b/www/errorreport.php
deleted file mode 100644
index f2670417faf785545c193f98ae9a712e686bd5da..0000000000000000000000000000000000000000
--- a/www/errorreport.php
+++ /dev/null
@@ -1,64 +0,0 @@
-<?php
-
-require_once('_include.php');
-
-$config = \SimpleSAML\Configuration::getInstance();
-
-// this page will redirect to itself after processing a POST request and sending the email
-if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
-    // the message has been sent. Show error report page
-
-    $t = new \SimpleSAML\XHTML\Template($config, 'errorreport.twig');
-    $t->send();
-    exit;
-}
-
-$reportId = $_REQUEST['reportId'];
-$email = $_REQUEST['email'];
-$text = $_REQUEST['text'];
-
-if (!preg_match('/^[0-9a-f]{8}$/', $reportId)) {
-    throw new \SimpleSAML\Error\Exception('Invalid reportID');
-}
-
-$data = null;
-try {
-    $session = \SimpleSAML\Session::getSessionFromRequest();
-    $data = $session->getData('core:errorreport', $reportId);
-} catch (\Exception $e) {
-    \SimpleSAML\Logger::error('Error loading error report data: ' . var_export($e->getMessage(), true));
-}
-
-if ($data === null) {
-    $data = [
-        'exceptionMsg'   => 'not set',
-        'exceptionTrace' => 'not set',
-        'trackId'        => 'not set',
-        'url'            => 'not set',
-        'referer'        => 'not set',
-    ];
-
-    if (isset($session)) {
-        $data['trackId'] = $session->getTrackID();
-    }
-}
-
-$data['reportId'] = $reportId;
-$data['version'] = $config->getVersion();
-$data['hostname'] = php_uname('n');
-$data['directory'] = dirname(dirname(__FILE__));
-
-if ($config->getOptionalBoolean('errorreporting', true)) {
-    $mail = new SimpleSAML\Utils\EMail('SimpleSAMLphp error report from ' . $email);
-    $mail->setData($data);
-    if (filter_var($email, FILTER_VALIDATE_EMAIL, FILTER_REQUIRE_SCALAR)) {
-        $mail->addReplyTo($email);
-    }
-    $mail->setText($text);
-    $mail->send();
-    SimpleSAML\Logger::error('Report with id ' . $reportId . ' sent');
-}
-
-// redirect the user back to this page to clear the POST request
-$httpUtils = new \SimpleSAML\Utils\HTTP();
-$httpUtils->redirectTrustedURL($httpUtils->getSelfURLNoQuery());