diff --git a/src/Glpi/Controller/AbstractController.php b/src/Glpi/Controller/AbstractController.php
new file mode 100644
index 00000000000..cd010b51579
--- /dev/null
+++ b/src/Glpi/Controller/AbstractController.php
@@ -0,0 +1,78 @@
+.
+ *
+ * ---------------------------------------------------------------------
+ */
+
+namespace Glpi\Controller;
+
+use Glpi\Application\View\TemplateRenderer;
+use Glpi\DependencyInjection\PublicService;
+use Symfony\Component\HttpFoundation\Response;
+
+abstract class AbstractController implements PublicService
+{
+ /**
+ * Helper method to get a response containing the content of a rendered
+ * twig template.
+ *
+ * @param string $view Path to a twig template, which will be looked for in
+ * the "templates" folder.
+ * For example, "my_template.html.twig" will be resolved to `templates/my_template.html.twig`.
+ * For plugins, you must use the "@my_plugin_name" prefix.
+ * For example, "@formcreator/my_template.html.twig will resolve to
+ * `(plugins|marketplace)/formcreator/templates/my_template.html.twig`.
+ * @param array $parameters The expected parameters of the twig template.
+ * @param Response $response Optional parameter which serves as the "base"
+ * response into which the renderer twig content will be inserted.
+ * You should only use it if you need to set some specific headers into the
+ * response or to set an http return code different than 200.
+ *
+ * @return Response
+ */
+ final protected function render(
+ string $view,
+ array $parameters = [],
+ Response $response = new Response(),
+ ): Response {
+ $twig = TemplateRenderer::getInstance();
+
+ // We must use output buffering here as Html::header() and Html::footer()
+ // output content directly.
+ // TODO: fix header() and footer() methods and remove output buffering
+ ob_start();
+ $twig->display($view, $parameters);
+ $content = ob_get_clean();
+
+ $response->setContent($content);
+ return $response;
+ }
+}
diff --git a/src/Glpi/Controller/ApiController.php b/src/Glpi/Controller/ApiController.php
index 9b36771ff83..cd22c20b6bb 100644
--- a/src/Glpi/Controller/ApiController.php
+++ b/src/Glpi/Controller/ApiController.php
@@ -34,7 +34,7 @@
namespace Glpi\Controller;
-use Glpi\Api\HL\Controller\AbstractController;
+use Glpi\Api\HL\Controller\AbstractController as ApiAbstractController;
use Glpi\Api\HL\Router;
use Glpi\Application\ErrorHandler;
use Glpi\Http\JSONResponse;
@@ -46,7 +46,7 @@
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
-final readonly class ApiController implements Controller
+final class ApiController extends AbstractController
{
#[Route(
"/api.php{request_parameters}",
@@ -105,8 +105,8 @@ private function call(): void
$response->send();
} catch (\InvalidArgumentException $e) {
$response = new JSONResponse(
- AbstractController::getErrorResponseBody(
- AbstractController::ERROR_INVALID_PARAMETER,
+ ApiAbstractController::getErrorResponseBody(
+ ApiAbstractController::ERROR_INVALID_PARAMETER,
$e->getMessage()
),
400
diff --git a/src/Glpi/Controller/ApiRestController.php b/src/Glpi/Controller/ApiRestController.php
index dfbc613acc4..6541d35aeb7 100644
--- a/src/Glpi/Controller/ApiRestController.php
+++ b/src/Glpi/Controller/ApiRestController.php
@@ -42,7 +42,7 @@
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
-final readonly class ApiRestController implements Controller
+final class ApiRestController extends AbstractController
{
#[Route(
"/apirest.php{request_parameters}",
diff --git a/src/Glpi/Controller/CaldavController.php b/src/Glpi/Controller/CaldavController.php
index 594b76c2ab2..d34e36f70cb 100644
--- a/src/Glpi/Controller/CaldavController.php
+++ b/src/Glpi/Controller/CaldavController.php
@@ -40,7 +40,7 @@
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
-final readonly class CaldavController implements Controller
+final class CaldavController extends AbstractController
{
#[Route(
"/caldav.php{request_parameters}",
diff --git a/src/Glpi/Controller/Controller.php b/src/Glpi/Controller/Controller.php
deleted file mode 100644
index 46b4161a141..00000000000
--- a/src/Glpi/Controller/Controller.php
+++ /dev/null
@@ -1,44 +0,0 @@
-.
- *
- * ---------------------------------------------------------------------
- */
-
-namespace Glpi\Controller;
-
-use Glpi\DependencyInjection\PublicService;
-use Symfony\Component\HttpFoundation\Request;
-use Symfony\Component\HttpFoundation\Response;
-
-interface Controller extends PublicService
-{
- public function __invoke(Request $request): Response;
-}
diff --git a/src/Glpi/Controller/ErrorController.php b/src/Glpi/Controller/ErrorController.php
index 03d18c1cdae..a901102572c 100644
--- a/src/Glpi/Controller/ErrorController.php
+++ b/src/Glpi/Controller/ErrorController.php
@@ -43,7 +43,7 @@
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
-class ErrorController implements Controller
+class ErrorController extends AbstractController
{
public function __invoke(Request $request, ?\Throwable $exception = null): Response
{
diff --git a/src/Glpi/Controller/Form/TagListController.php b/src/Glpi/Controller/Form/TagListController.php
index 92cf396685a..ea3c645a55b 100644
--- a/src/Glpi/Controller/Form/TagListController.php
+++ b/src/Glpi/Controller/Form/TagListController.php
@@ -34,7 +34,7 @@
namespace Glpi\Controller\Form;
-use Glpi\Controller\Controller;
+use Glpi\Controller\AbstractController;
use Glpi\Form\Form;
use Glpi\Form\Tag\FormTagsManager;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -45,7 +45,7 @@
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
-final class TagListController implements Controller
+final class TagListController extends AbstractController
{
#[Route(
"/Form/TagList",
diff --git a/src/Glpi/Controller/IndexController.php b/src/Glpi/Controller/IndexController.php
index 1c0e44ef8e9..bfdb4768476 100644
--- a/src/Glpi/Controller/IndexController.php
+++ b/src/Glpi/Controller/IndexController.php
@@ -48,7 +48,7 @@
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
-final readonly class IndexController implements Controller
+final class IndexController extends AbstractController
{
#[Route(
[
diff --git a/src/Glpi/Controller/StatusController.php b/src/Glpi/Controller/StatusController.php
index be97245bd27..e17c795cdd8 100644
--- a/src/Glpi/Controller/StatusController.php
+++ b/src/Glpi/Controller/StatusController.php
@@ -44,7 +44,7 @@
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
-final readonly class StatusController implements Controller
+final class StatusController extends AbstractController
{
#[Route(
"/status.php",
diff --git a/templates/layout/page_skeleton.html.twig b/templates/layout/page_skeleton.html.twig
new file mode 100644
index 00000000000..d2f1ac77746
--- /dev/null
+++ b/templates/layout/page_skeleton.html.twig
@@ -0,0 +1,43 @@
+{#
+ # ---------------------------------------------------------------------
+ #
+ # GLPI - Gestionnaire Libre de Parc Informatique
+ #
+ # http://glpi-project.org
+ #
+ # @copyright 2015-2024 Teclib' and contributors.
+ # @licence https://www.gnu.org/licenses/gpl-3.0.html
+ #
+ # ---------------------------------------------------------------------
+ #
+ # LICENSE
+ #
+ # This file is part of GLPI.
+ #
+ # This program is free software: you can redistribute it and/or modify
+ # it under the terms of the GNU General Public License as published by
+ # the Free Software Foundation, either version 3 of the License, or
+ # (at your option) any later version.
+ #
+ # This program is distributed in the hope that it will be useful,
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ # GNU General Public License for more details.
+ #
+ # You should have received a copy of the GNU General Public License
+ # along with this program. If not, see