Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.56% covered (success)
80.56%
58 / 72
83.33% covered (success)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebExceptionListener
80.56% covered (success)
80.56%
58 / 72
83.33% covered (success)
83.33%
5 / 6
24.24
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onKernelException
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
9
 handleNotFound
44.00% covered (danger)
44.00%
11 / 25
0.00% covered (danger)
0.00%
0 / 1
15.61
 handleServerError
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 handleErrorResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 formatExceptionMessage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Exception listener for web (non-API) requests
5 *
6 * Handles exceptions by rendering appropriate error pages or redirecting.
7 *
8 * This Source Code Form is subject to the terms of the Mozilla Public License,
9 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
10 * obtain one at https://mozilla.org/MPL/2.0/.
11 *
12 * @package   phpMyFAQ
13 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
14 * @copyright 2026 phpMyFAQ Team
15 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
16 * @link      https://www.phpmyfaq.de
17 * @since     2026-02-15
18 */
19
20declare(strict_types=1);
21
22namespace phpMyFAQ\EventListener;
23
24use phpMyFAQ\Controller\AbstractController;
25use phpMyFAQ\Controller\ContainerControllerResolver;
26use phpMyFAQ\Controller\Exception\ForbiddenException;
27use phpMyFAQ\Controller\Frontend\PageNotFoundController;
28use phpMyFAQ\Environment;
29use Symfony\Component\DependencyInjection\ContainerInterface;
30use Symfony\Component\HttpFoundation\Exception\BadRequestException;
31use Symfony\Component\HttpFoundation\RedirectResponse;
32use Symfony\Component\HttpFoundation\Response;
33use Symfony\Component\HttpKernel\Controller\ArgumentResolver;
34use Symfony\Component\HttpKernel\Event\ExceptionEvent;
35use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
36use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
37use Symfony\Component\Routing\Exception\ResourceNotFoundException;
38use Throwable;
39
40readonly class WebExceptionListener
41{
42    public function __construct(
43        private ?ContainerInterface $container = null,
44    ) {
45    }
46
47    public function onKernelException(ExceptionEvent $event): void
48    {
49        $request = $event->getRequest();
50        $pathInfo = $request->getPathInfo();
51        $baseUrl = '/' . ltrim(rtrim($request->getBaseUrl(), characters: '/'), characters: '/');
52        $loginPath = $baseUrl === '/' ? '/login' : $baseUrl . '/login';
53
54        // Skip API requests — handled by ApiExceptionListener
55        if (str_starts_with($pathInfo, '/api/') || (bool) $request->attributes->get('_api_context', false)) {
56            return;
57        }
58
59        $throwable = $event->getThrowable();
60
61        $response = match (true) {
62            $throwable instanceof ResourceNotFoundException,
63            $throwable instanceof NotFoundHttpException,
64                => $this->handleNotFound($event),
65            $throwable instanceof UnauthorizedHttpException => new RedirectResponse(url: $loginPath),
66            $throwable instanceof ForbiddenException => $this->handleErrorResponse(
67                'An error occurred: :message at line :line at :file',
68                'Forbidden',
69                Response::HTTP_FORBIDDEN,
70                $throwable,
71            ),
72            $throwable instanceof BadRequestException => $this->handleErrorResponse(
73                'An error occurred: :message at line :line at :file',
74                'Bad Request',
75                Response::HTTP_BAD_REQUEST,
76                $throwable,
77            ),
78            default => $this->handleServerError($throwable),
79        };
80
81        $event->setResponse($response);
82    }
83
84    private function handleNotFound(ExceptionEvent $event): Response
85    {
86        $request = $event->getRequest();
87        $throwable = $event->getThrowable();
88
89        try {
90            if (!$this->container instanceof ContainerInterface) {
91                throw new \RuntimeException('Container is required to render the styled 404 page.');
92            }
93
94            $request->attributes->set('_route', 'public.404');
95            $request->attributes->set('_controller', PageNotFoundController::class . '::index');
96            $controllerResolver = new ContainerControllerResolver($this->container);
97            $argumentResolver = new ArgumentResolver();
98            $controller = $controllerResolver->getController($request);
99            if ($controller === false) {
100                throw new \RuntimeException('No controller found for the 404 route.');
101            }
102
103            if (is_array($controller) && $controller[0] instanceof AbstractController) {
104                $controller[0]->setContainer($this->container);
105            }
106
107            /* @mago-expect analysis:less-specific-nested-argument-type - Symfony's resolver returns a generic callable */
108            $arguments = $argumentResolver->getArguments($request, $controller);
109            /* @mago-expect analysis:less-specific-nested-argument-type - Symfony's resolver returns a generic callable */
110            $response = call_user_func_array($controller, $arguments);
111            if (!$response instanceof Response) {
112                throw new \RuntimeException('The 404 controller did not return a response.');
113            }
114
115            return $response;
116        } catch (Throwable) {
117            return $this->handleErrorResponse(
118                'Not Found: :message at line :line at :file',
119                'Not Found',
120                Response::HTTP_NOT_FOUND,
121                $throwable,
122            );
123        }
124    }
125
126    private function handleServerError(Throwable $throwable): Response
127    {
128        error_log(sprintf(
129            'Unhandled exception: %s at %s:%d',
130            $throwable->getMessage(),
131            $throwable->getFile(),
132            $throwable->getLine(),
133        ));
134
135        return $this->handleErrorResponse(
136            'Internal Server Error: :message at line :line at :file',
137            'Internal Server Error',
138            Response::HTTP_INTERNAL_SERVER_ERROR,
139            $throwable,
140        );
141    }
142
143    private function handleErrorResponse(
144        string $debugTemplate,
145        string $fallbackMessage,
146        int $statusCode,
147        Throwable $throwable,
148    ): Response {
149        $message = Environment::isDebugMode()
150            ? $this->formatExceptionMessage($debugTemplate, $throwable)
151            : $fallbackMessage;
152
153        return new Response(content: $message, status: $statusCode);
154    }
155
156    private function formatExceptionMessage(string $template, Throwable $throwable): string
157    {
158        return strtr($template, [
159            ':message' => $throwable->getMessage(),
160            ':line' => (string) $throwable->getLine(),
161            ':file' => $throwable->getFile(),
162        ]);
163    }
164}