Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.82% covered (warning)
75.82%
69 / 91
58.33% covered (warning)
58.33%
7 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Kernel
75.82% covered (warning)
75.82%
69 / 91
58.33% covered (warning)
58.33%
7 / 12
58.40
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
 boot
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 handle
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 getContainer
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getRoutingContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDebug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildContainer
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 resolveContainer
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
15.40
 createContainerBuilder
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
2.50
 loadRoutes
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 createHttpKernel
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 registerEventListeners
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2
3/**
4 * The phpMyFAQ Kernel
5 *
6 * The phpMyFAQ Kernel provides event-based request handling via Symfony HttpKernel,
7 * with exception listeners and a single shared DI container.
8 *
9 * This Source Code Form is subject to the terms of the Mozilla Public License,
10 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
11 * obtain one at https://mozilla.org/MPL/2.0/.
12 *
13 * @package   phpMyFAQ
14 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
15 * @copyright 2026 phpMyFAQ Team
16 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
17 * @link      https://www.phpmyfaq.de
18 * @since     2026-02-15
19 */
20
21declare(strict_types=1);
22
23namespace phpMyFAQ;
24
25use phpMyFAQ\Container\ContainerCacheManager;
26use phpMyFAQ\Container\ContainerRegistry;
27use phpMyFAQ\Controller\ContainerControllerResolver;
28use phpMyFAQ\EventListener\ApiExceptionListener;
29use phpMyFAQ\EventListener\ApiRateLimiterListener;
30use phpMyFAQ\EventListener\ControllerContainerListener;
31use phpMyFAQ\EventListener\LanguageListener;
32use phpMyFAQ\EventListener\RouterListener;
33use phpMyFAQ\EventListener\WebExceptionListener;
34use phpMyFAQ\Form\FormsServiceProvider;
35use phpMyFAQ\Http\RateLimiter;
36use phpMyFAQ\Routing\RouteCacheManager;
37use phpMyFAQ\Routing\RouteCollectionBuilder;
38use Symfony\Component\Config\FileLocator;
39use Symfony\Component\DependencyInjection\ContainerBuilder;
40use Symfony\Component\DependencyInjection\ContainerInterface;
41use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
42use Symfony\Component\EventDispatcher\EventDispatcher;
43use Symfony\Component\HttpFoundation\Request;
44use Symfony\Component\HttpFoundation\RequestStack;
45use Symfony\Component\HttpFoundation\Response;
46use Symfony\Component\HttpKernel\Controller\ArgumentResolver;
47use Symfony\Component\HttpKernel\HttpKernel;
48use Symfony\Component\HttpKernel\HttpKernelInterface;
49use Symfony\Component\HttpKernel\KernelEvents;
50use Symfony\Component\Routing\RouteCollection;
51
52class Kernel implements HttpKernelInterface
53{
54    private ?ContainerInterface $container = null;
55
56    private ?HttpKernel $httpKernel = null;
57
58    private bool $booted = false;
59
60    private ?RouteCollection $routes = null;
61
62    public function __construct(
63        private readonly string $routingContext = 'public',
64        private readonly bool $debug = false,
65    ) {
66    }
67
68    /**
69     * Boots the Kernel: builds the DI container, loads routes, registers listeners, and creates the HttpKernel.
70     */
71    public function boot(): void
72    {
73        if ($this->booted) {
74            return;
75        }
76
77        $container = $this->buildContainer();
78        $this->container = $container;
79        ContainerRegistry::set($container);
80
81        $routes = $this->loadRoutes($container);
82        $this->routes = $routes;
83
84        $this->httpKernel = $this->createHttpKernel($container, $routes);
85        $this->booted = true;
86    }
87
88    public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response
89    {
90        if (!$this->booted) {
91            $this->boot();
92        }
93
94        // Mark API context on the request for exception listeners
95        if ($this->routingContext === 'api' || $this->routingContext === 'admin-api') {
96            $request->attributes->set('_api_context', true);
97        }
98
99        if ($this->httpKernel === null) {
100            throw new \LogicException('The Kernel booted without creating an HttpKernel.');
101        }
102
103        return $this->httpKernel->handle($request, $type, $catch);
104    }
105
106    public function getContainer(): ContainerInterface
107    {
108        if (!$this->booted) {
109            $this->boot();
110        }
111
112        if ($this->container === null) {
113            throw new \LogicException('The Kernel booted without building a container.');
114        }
115
116        return $this->container;
117    }
118
119    public function getRoutingContext(): string
120    {
121        return $this->routingContext;
122    }
123
124    public function isDebug(): bool
125    {
126        return $this->debug;
127    }
128
129    private function buildContainer(): ContainerInterface
130    {
131        $container = $this->resolveContainer();
132
133        // Register kernel-level services ('kernel' is declared synthetic in the builder)
134        $container->set('kernel', $this);
135
136        return $container;
137    }
138
139    private function resolveContainer(): ContainerInterface
140    {
141        $cacheEnabled = filter_var(Environment::get('CONTAINER_CACHE_ENABLED', 'true'), FILTER_VALIDATE_BOOLEAN);
142        $cacheDisabled = $this->debug || Environment::isDebugMode() || System::isDevelopmentVersion();
143
144        if (!$cacheEnabled || $cacheDisabled) {
145            return $this->createContainerBuilder();
146        }
147
148        /** @var mixed $configuredCacheDir */
149        $configuredCacheDir = Environment::get('CONTAINER_CACHE_DIR');
150        $cacheDir = is_string($configuredCacheDir) && trim($configuredCacheDir) !== ''
151            ? $configuredCacheDir
152            : (string) PMF_ROOT_DIR . '/cache/container';
153
154        return new ContainerCacheManager($cacheDir)->getContainer($this->createContainerBuilder(...));
155    }
156
157    private function createContainerBuilder(): ContainerBuilder
158    {
159        $containerBuilder = new ContainerBuilder();
160        $phpFileLoader = new PhpFileLoader($containerBuilder, new FileLocator(PMF_SRC_DIR));
161
162        try {
163            $phpFileLoader->load(resource: 'services.php');
164        } catch (\Throwable $exception) {
165            throw new \RuntimeException(
166                'Kernel boot failed while loading "services.php"; cannot resolve "phpmyfaq.event_dispatcher".',
167                0,
168                $exception,
169            );
170        }
171
172        // Register Forms services
173        FormsServiceProvider::register($containerBuilder);
174
175        // The Kernel instance itself is injected after the container is built or loaded
176        // from the compiled cache, so it must survive compilation as a synthetic service.
177        $containerBuilder->register('kernel')->setSynthetic(true)->setPublic(true);
178
179        return $containerBuilder;
180    }
181
182    private function loadRoutes(ContainerInterface $container): RouteCollection
183    {
184        $configurationService = $container->get(id: 'phpmyfaq.configuration');
185        $configuration = $configurationService instanceof Configuration ? $configurationService : null;
186
187        $cacheEnabled = filter_var(Environment::get('ROUTING_CACHE_ENABLED', 'true'), FILTER_VALIDATE_BOOLEAN);
188        $cacheDir = (string) Environment::get('ROUTING_CACHE_DIR', (string) PMF_ROOT_DIR . '/cache/routes');
189
190        if ($cacheEnabled && !$this->debug && !Environment::isDebugMode()) {
191            $cacheManager = new RouteCacheManager($cacheDir, Environment::isDebugMode());
192            return $cacheManager->getRoutes($this->routingContext, function () use ($configuration) {
193                $builder = new RouteCollectionBuilder($configuration);
194                return $builder->build($this->routingContext);
195            });
196        }
197
198        $builder = new RouteCollectionBuilder($configuration);
199        return $builder->build($this->routingContext);
200    }
201
202    private function createHttpKernel(ContainerInterface $container, RouteCollection $routes): HttpKernel
203    {
204        $dispatcher = $container->get('phpmyfaq.event_dispatcher');
205
206        if (!$dispatcher instanceof EventDispatcher) {
207            $dispatcher = new EventDispatcher();
208        }
209
210        $this->registerEventListeners($dispatcher, $container, $routes);
211
212        $controllerResolver = new ContainerControllerResolver($container);
213        $requestStack = new RequestStack();
214        $argumentResolver = new ArgumentResolver();
215
216        return new HttpKernel($dispatcher, $controllerResolver, $requestStack, $argumentResolver);
217    }
218
219    private function registerEventListeners(
220        EventDispatcher $dispatcher,
221        ContainerInterface $container,
222        RouteCollection $routes,
223    ): void {
224        // Language listener — initializes Strings and translations (priority 300, runs before router
225        // so that the 404/error pages rendered from an exception still have translations available)
226        $languageListener = new LanguageListener($container);
227        $dispatcher->addListener(KernelEvents::REQUEST, [$languageListener, 'onKernelRequest'], 300);
228
229        // Router listener — matches request to route (priority 256)
230        $routerListener = new RouterListener($routes);
231        $dispatcher->addListener(KernelEvents::REQUEST, [$routerListener, 'onKernelRequest'], 256);
232
233        if (
234            $this->routingContext === 'api'
235            && $container->has('phpmyfaq.configuration')
236            && $container->has('phpmyfaq.http.rate-limiter')
237        ) {
238            $rateLimiterConfiguration = $container->get('phpmyfaq.configuration');
239            $rateLimiter = $container->get('phpmyfaq.http.rate-limiter');
240            if ($rateLimiterConfiguration instanceof Configuration && $rateLimiter instanceof RateLimiter) {
241                $apiRateLimiterListener = new ApiRateLimiterListener($rateLimiterConfiguration, $rateLimiter);
242                $dispatcher->addListener(KernelEvents::REQUEST, [$apiRateLimiterListener, 'onKernelRequest'], 150);
243            }
244        }
245
246        // API exception listener — converts exceptions to RFC 7807 JSON (priority 0)
247        $configurationService = $container->has('phpmyfaq.configuration')
248            ? $container->get('phpmyfaq.configuration')
249            : null;
250        $apiExceptionListener = new ApiExceptionListener(
251            $configurationService instanceof Configuration ? $configurationService : null,
252        );
253        $dispatcher->addListener(KernelEvents::EXCEPTION, [$apiExceptionListener, 'onKernelException'], 0);
254
255        // Web exception listener — handles web (non-API) exceptions (priority -10, after API listener)
256        $webExceptionListener = new WebExceptionListener($container);
257        $dispatcher->addListener(KernelEvents::EXCEPTION, [$webExceptionListener, 'onKernelException'], -10);
258
259        // Controller container listener — injects shared container into controllers
260        $controllerContainerListener = new ControllerContainerListener($container);
261        $dispatcher->addListener(KernelEvents::CONTROLLER, [$controllerContainerListener, 'onKernelController'], 0);
262    }
263}