Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
RouterListener
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
3 / 3
12
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onKernelRequest
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 normalizePath
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3/**
4 * Router listener for matching requests to routes
5 *
6 * Matches incoming requests against the route collection and sets request attributes.
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 Symfony\Component\HttpKernel\Event\RequestEvent;
25use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
26use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
27use Symfony\Component\Routing\Exception\MethodNotAllowedException;
28use Symfony\Component\Routing\Exception\ResourceNotFoundException;
29use Symfony\Component\Routing\Matcher\UrlMatcher;
30use Symfony\Component\Routing\RequestContext;
31use Symfony\Component\Routing\RouteCollection;
32
33readonly class RouterListener
34{
35    public function __construct(
36        private RouteCollection $routes,
37    ) {
38    }
39
40    public function onKernelRequest(RequestEvent $event): void
41    {
42        if (!$event->isMainRequest()) {
43            return;
44        }
45
46        $request = $event->getRequest();
47
48        // Skip if already matched (e.g., by sub-request or test)
49        if ($request->attributes->has('_controller')) {
50            return;
51        }
52
53        $requestContext = new RequestContext();
54        $requestContext->fromRequest($request);
55
56        $urlMatcher = new UrlMatcher($this->routes, $requestContext);
57        try {
58            $pathInfo = $this->normalizePath($request->getPathInfo());
59            $parameters = $urlMatcher->match($pathInfo);
60        } catch (ResourceNotFoundException $exception) {
61            throw new NotFoundHttpException($exception->getMessage(), $exception);
62        } catch (MethodNotAllowedException $exception) {
63            throw new MethodNotAllowedHttpException(
64                $exception->getAllowedMethods(),
65                $exception->getMessage(),
66                $exception,
67            );
68        }
69
70        $normalizedParameters = [];
71        foreach ($parameters as $parameterName => $parameterValue) {
72            $normalizedParameters[(string) $parameterName] = $parameterValue;
73        }
74
75        $request->attributes->add($normalizedParameters);
76    }
77
78    /**
79     * Normalizes the path by removing trailing slashes and /index.php suffix.
80     */
81    private function normalizePath(string $path): string
82    {
83        // Strip /index.php suffix (e.g. /update/index.php → /update)
84        if (str_ends_with($path, '/index.php')) {
85            $path = substr($path, offset: 0, length: -10);
86        }
87
88        // Remove trailing slash, but keep root path as /
89        if ($path !== '/' && str_ends_with($path, '/')) {
90            $path = rtrim($path, characters: '/');
91        }
92
93        return $path === '' ? '/' : $path;
94    }
95}