Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.59% covered (success)
92.59%
75 / 81
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
AttributeRouteLoader
92.59% covered (success)
92.59%
75 / 81
60.00% covered (warning)
60.00%
3 / 5
35.50
0.00% covered (danger)
0.00%
0 / 1
 load
86.36% covered (success)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
12.37
 findControllerFiles
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 getClassFromFile
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 createRouteFromAttribute
88.89% covered (success)
88.89%
24 / 27
0.00% covered (danger)
0.00%
0 / 1
3.01
 matchesContext
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2
3/**
4 * Attribute-based route loader
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-01-18
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Routing;
21
22use Exception;
23use FilesystemIterator;
24use RecursiveDirectoryIterator;
25use RecursiveIteratorIterator;
26use ReflectionAttribute;
27use ReflectionClass;
28use ReflectionException;
29use ReflectionMethod;
30use Symfony\Component\Routing\Attribute\Route;
31use Symfony\Component\Routing\Route as SymfonyRoute;
32use Symfony\Component\Routing\RouteCollection;
33
34/**
35 * Class AttributeRouteLoader
36 *
37 * Scans controller directories for classes with #[Route] attributes
38 * and builds a RouteCollection from discovered routes.
39 */
40class AttributeRouteLoader
41{
42    /**
43     * Load routes from controller attributes in the specified directory.
44     *
45     * @param string $controllerDir The directory to scan for controllers
46     * @param string $context The routing context ('public', 'admin', 'api')
47     * @return RouteCollection Collection of discovered routes
48     */
49    public function load(string $controllerDir, string $context = 'public'): RouteCollection
50    {
51        $routes = new RouteCollection();
52
53        if (!is_dir($controllerDir)) {
54            return $routes;
55        }
56
57        $files = $this->findControllerFiles($controllerDir);
58
59        foreach ($files as $file) {
60            $class = $this->getClassFromFile($file);
61            if (!$class || !class_exists($class)) {
62                continue;
63            }
64
65            try {
66                $reflectionClass = new ReflectionClass($class);
67                foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
68                    $attributes = $method->getAttributes(Route::class);
69                    foreach ($attributes as $attribute) {
70                        $route = $this->createRouteFromAttribute($attribute, $class, $method->getName());
71                        if ($route && $this->matchesContext($route, $context)) {
72                            $routeName = $route->getDefault('_route');
73                            $name = is_string($routeName) && $routeName !== ''
74                                ? $routeName
75                                : 'route_' . md5($class . $method->getName());
76                            $routes->add($name, $route);
77                        }
78                    }
79                }
80            } catch (ReflectionException) {
81                // Skip classes that can't be reflected
82                continue;
83            }
84        }
85
86        return $routes;
87    }
88
89    /**
90     * Find all PHP controller files in the directory.
91     *
92     * @param string $directory The directory to scan
93     * @return array<string> Array of file paths
94     */
95    private function findControllerFiles(string $directory): array
96    {
97        $files = [];
98
99        $iterator = new RecursiveIteratorIterator(
100            new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
101            RecursiveIteratorIterator::SELF_FIRST,
102        );
103
104        foreach ($iterator as $file) {
105            if (!$file instanceof \SplFileInfo || strtolower($file->getExtension()) !== 'php' || !$file->isFile()) {
106                continue;
107            }
108
109            $files[] = $file->getPathname();
110        }
111
112        return $files;
113    }
114
115    /**
116     * Extract the fully qualified class name from a PHP file.
117     *
118     * @param string $file The file path
119     * @return string|null The fully qualified class name or null if not found
120     */
121    private function getClassFromFile(string $file): ?string
122    {
123        $content = file_get_contents($file);
124        if ($content === false) {
125            return null;
126        }
127
128        // Extract namespace
129        $namespace = null;
130        $matches = [];
131        if (preg_match('/namespace\s+([^;]+);/', $content, $matches)) {
132            $namespace = $matches[1];
133        }
134
135        // Extract class name
136        $className = null;
137        if (preg_match('/(?:class|interface|trait)\s+(\w+)/', $content, $matches)) {
138            $className = $matches[1];
139        }
140
141        if ($namespace && $className) {
142            return $namespace . '\\' . $className;
143        }
144
145        return null;
146    }
147
148    /**
149     * Create a Symfony Route from a Route attribute.
150     *
151     * @param ReflectionAttribute $attribute The Route attribute
152     * @param string $class The controller class name
153     * @param string $method The controller method name
154     * @return SymfonyRoute|null The created route or null if invalid
155     */
156    private function createRouteFromAttribute(
157        ReflectionAttribute $attribute,
158        string $class,
159        string $method,
160    ): ?SymfonyRoute {
161        try {
162            /** @var Route $routeAttribute */
163            $routeAttribute = $attribute->newInstance();
164
165            // Extract route properties
166            $path = $routeAttribute->path;
167            if (!is_string($path)) {
168                return null;
169            }
170
171            $name = $routeAttribute->name ?? '';
172            $methods = $routeAttribute->methods;
173            $defaults = $routeAttribute->defaults;
174            $requirements = $routeAttribute->requirements;
175            $options = $routeAttribute->options;
176            $host = $routeAttribute->host;
177            $schemes = $routeAttribute->schemes;
178            $condition = $routeAttribute->condition;
179
180            // Set controller in defaults
181            $defaults['_controller'] = $class . '::' . $method;
182
183            // Create Symfony Route
184            $route = new SymfonyRoute(
185                path: $path,
186                defaults: $defaults,
187                requirements: $requirements,
188                options: $options,
189                host: $host,
190                schemes: $schemes,
191                methods: $methods,
192                condition: $condition,
193            );
194
195            // Store the name in the route for later retrieval
196            $route->setDefault('_route', $name);
197
198            return $route;
199        } catch (Exception) {
200            return null;
201        }
202    }
203
204    /**
205     * Check if a route matches the specified context.
206     *
207     * @param SymfonyRoute $route The route to check
208     * @param string $context The context to match against
209     * @return bool True if the route matches the context
210     */
211    private function matchesContext(SymfonyRoute $route, string $context): bool
212    {
213        $routeName = (string) ($route->getDefault('_route') ?? '');
214
215        if ($routeName === '') {
216            return true; // Allow routes without names
217        }
218
219        return match ($context) {
220            'admin' => str_starts_with($routeName, 'admin.') && !str_starts_with($routeName, 'admin.api.'),
221            'admin-api' => str_starts_with($routeName, 'admin.api.'),
222            'api' => str_starts_with($routeName, 'api.') && !str_starts_with($routeName, 'admin.api.'),
223            'public' => str_starts_with($routeName, 'public.'),
224            default => true,
225        };
226    }
227}