Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.65% covered (success)
93.65%
59 / 63
88.89% covered (success)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
RouteCacheManager
93.65% covered (success)
93.65%
59 / 63
88.89% covered (success)
88.89%
8 / 9
21.11
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getRoutes
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
6.84
 clear
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 clearContext
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 hasCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCacheFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeCache
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 generateRouteCode
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 readCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * Route cache manager
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 Symfony\Component\Routing\Route;
23use Symfony\Component\Routing\RouteCollection;
24
25/**
26 * Class RouteCacheManager
27 *
28 * Manages route caching for improved performance in production.
29 * Caches compiled RouteCollection to a PHP file and handles cache invalidation.
30 */
31class RouteCacheManager
32{
33    private string $cacheDir;
34    private bool $debug;
35
36    public function __construct(string $cacheDir, bool $debug = false)
37    {
38        $this->cacheDir = rtrim(string: $cacheDir, characters: '/');
39        $this->debug = $debug;
40
41        // Create a cache directory if it doesn't exist
42        if (!is_dir($this->cacheDir)) {
43            mkdir(directory: $this->cacheDir, permissions: 0o755, recursive: true);
44        }
45    }
46
47    /**
48     * Get routes, either from cache or by loading them.
49     *
50     * @param string $context The routing context
51     * @param callable $loader Callback that loads the routes
52     * @return RouteCollection The route collection
53     */
54    public function getRoutes(string $context, callable $loader): RouteCollection
55    {
56        $cacheFile = $this->getCacheFile($context);
57
58        // In debug mode always reload; otherwise use the cache when it holds
59        // a valid collection - a corrupted cache file falls through to reload.
60        if (!$this->debug && file_exists($cacheFile)) {
61            $cachedRoutes = $this->readCache($cacheFile);
62            if ($cachedRoutes instanceof RouteCollection) {
63                return $cachedRoutes;
64            }
65        }
66
67        $routes = $loader();
68        if (!$routes instanceof RouteCollection) {
69            throw new \RuntimeException(sprintf(
70                'Route loader for context "%s" did not return a RouteCollection.',
71                $context,
72            ));
73        }
74
75        // Only write cache in production mode
76        if (!$this->debug) {
77            $this->writeCache($cacheFile, $routes);
78        }
79
80        return $routes;
81    }
82
83    /**
84     * Clear all route caches.
85     */
86    public function clear(): void
87    {
88        $pattern = $this->cacheDir . '/routes_*.php';
89        $cacheFiles = glob($pattern);
90
91        if ($cacheFiles !== false) {
92            foreach ($cacheFiles as $file) {
93                if (!file_exists($file)) {
94                    continue;
95                }
96
97                unlink($file);
98            }
99        }
100    }
101
102    /**
103     * Clear cache for a specific context.
104     *
105     * @param string $context The routing context
106     */
107    public function clearContext(string $context): void
108    {
109        $cacheFile = $this->getCacheFile($context);
110
111        if (file_exists($cacheFile)) {
112            unlink($cacheFile);
113        }
114    }
115
116    /**
117     * Check if a cache exists for a context.
118     *
119     * @param string $context The routing context
120     * @return bool True if a cache exists
121     */
122    public function hasCache(string $context): bool
123    {
124        return file_exists($this->getCacheFile($context));
125    }
126
127    /**
128     * Get the cache file path for a context.
129     *
130     * @param string $context The routing context
131     * @return string The cache file path
132     */
133    private function getCacheFile(string $context): string
134    {
135        return $this->cacheDir . '/routes_' . $context . '.php';
136    }
137
138    /**
139     * Write a route collection to a cache file.
140     *
141     * @param string $cacheFile The cache file path
142     * @param RouteCollection $routes The routes to cache
143     */
144    private function writeCache(string $cacheFile, RouteCollection $routes): void
145    {
146        $content = '<?php' . PHP_EOL . PHP_EOL;
147        $content .= '//' . PHP_EOL;
148        $content .= '// This file is auto-generated by phpMyFAQ RouteCacheManager' . PHP_EOL;
149        $content .= '// Do not edit this file manually' . PHP_EOL . PHP_EOL;
150        $content .= '//' . PHP_EOL;
151        $content .= 'use Symfony\Component\Routing\Route;' . PHP_EOL;
152        $content .= 'use Symfony\Component\Routing\RouteCollection;' . PHP_EOL . PHP_EOL;
153        $content .= '$routes = new RouteCollection();' . PHP_EOL . PHP_EOL;
154
155        foreach ($routes as $name => $route) {
156            $content .= $this->generateRouteCode($name, $route);
157        }
158
159        $content .= PHP_EOL . 'return $routes;' . PHP_EOL;
160
161        file_put_contents($cacheFile, $content);
162    }
163
164    /**
165     * Generate PHP code for a single route.
166     *
167     * @param string $name The route name
168     * @param Route $route The route
169     * @return string The generated PHP code
170     */
171    private function generateRouteCode(string $name, Route $route): string
172    {
173        $path = var_export(value: $route->getPath(), return: true);
174        $defaults = var_export(value: $route->getDefaults(), return: true);
175        $requirements = var_export(value: $route->getRequirements(), return: true);
176        $options = var_export(value: $route->getOptions(), return: true);
177        $host = var_export(value: $route->getHost(), return: true);
178        $schemes = var_export(value: $route->getSchemes(), return: true);
179        $methods = var_export(value: $route->getMethods(), return: true);
180        $condition = var_export(value: $route->getCondition(), return: true);
181
182        $code = '$routes->add(' . var_export(value: $name, return: true) . ', new Route(' . PHP_EOL;
183        $code .= '    path: ' . $path . ',' . PHP_EOL;
184        $code .= '    defaults: ' . $defaults . ',' . PHP_EOL;
185        $code .= '    requirements: ' . $requirements . ',' . PHP_EOL;
186        $code .= '    options: ' . $options . ',' . PHP_EOL;
187        $code .= '    host: ' . $host . ',' . PHP_EOL;
188        $code .= '    schemes: ' . $schemes . ',' . PHP_EOL;
189        $code .= '    methods: ' . $methods . ',' . PHP_EOL;
190        $code .= '    condition: ' . $condition . PHP_EOL;
191        $code .= '));' . PHP_EOL . PHP_EOL;
192
193        return $code;
194    }
195
196    /**
197     * Read route collection from a cache file.
198     *
199     * @param string $cacheFile The cache file path
200     * @return RouteCollection|null The cached routes, or null if the file is corrupted
201     */
202    private function readCache(string $cacheFile): ?RouteCollection
203    {
204        $routes = include $cacheFile;
205
206        return $routes instanceof RouteCollection ? $routes : null;
207    }
208}