Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.14% covered (success)
97.14%
34 / 35
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContainerCacheManager
97.14% covered (success)
97.14%
34 / 35
75.00% covered (warning)
75.00%
3 / 4
14
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
 getContainer
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
9
 containerClass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeCache
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
1<?php
2
3/**
4 * Compiled DI container cache manager
5 *
6 * Compiles the service container once, dumps it to a PHP class, and serves the dumped
7 * class on subsequent requests so services.php is not re-parsed per request.
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-07-14
19 */
20
21declare(strict_types=1);
22
23namespace phpMyFAQ\Container;
24
25use Symfony\Component\DependencyInjection\ContainerBuilder;
26use Symfony\Component\DependencyInjection\ContainerInterface;
27use Symfony\Component\DependencyInjection\Dumper\PhpDumper;
28use Throwable;
29
30final class ContainerCacheManager
31{
32    private readonly string $cacheDir;
33
34    /** @var callable(string): void */
35    private $logError;
36
37    /**
38     * @param callable(string): void|null $logError Defaults to error_log(); the container
39     *                                              is not built yet, so no PSR logger exists.
40     */
41    public function __construct(string $cacheDir, ?callable $logError = null)
42    {
43        $this->cacheDir = rtrim(string: $cacheDir, characters: '/');
44        $this->logError = $logError ?? error_log(...);
45
46        if (!is_dir($this->cacheDir)) {
47            mkdir(directory: $this->cacheDir, permissions: 0o755, recursive: true);
48        }
49    }
50
51    /**
52     * Returns the compiled container, dumping it to the cache on first use. Every service
53     * and alias is marked public before compiling because phpMyFAQ resolves services from
54     * the container at runtime. If compiling or dumping fails, the container falls back to
55     * a fresh, uncompiled builder, so the application keeps working without the cache.
56     *
57     * @param callable(): ContainerBuilder $containerBuilderFactory
58     */
59    public function getContainer(callable $containerBuilderFactory): ContainerInterface
60    {
61        $containerClass = $this->containerClass();
62        $cacheFile = $this->cacheDir . '/' . $containerClass . '.php';
63
64        if (!class_exists($containerClass, autoload: false) && is_file($cacheFile)) {
65            require_once $cacheFile;
66        }
67
68        if (class_exists($containerClass, autoload: false)) {
69            /* @mago-expect analysis:unknown-class-instantiation - the dumped container class only exists at runtime */
70            $container = new $containerClass();
71            if ($container instanceof ContainerInterface) {
72                return $container;
73            }
74        }
75
76        try {
77            $containerBuilder = $containerBuilderFactory();
78
79            foreach ($containerBuilder->getDefinitions() as $definition) {
80                $definition->setPublic(true);
81            }
82
83            foreach ($containerBuilder->getAliases() as $alias) {
84                $alias->setPublic(true);
85            }
86
87            $containerBuilder->compile();
88
89            $dump = new PhpDumper($containerBuilder)->dump(['class' => $containerClass]);
90            if (is_string($dump)) {
91                $this->writeCache($cacheFile, $dump);
92            }
93
94            return $containerBuilder;
95        } catch (Throwable $throwable) {
96            ($this->logError)(sprintf(
97                'phpMyFAQ: cannot compile the DI container (%s), falling back to the uncompiled container: %s',
98                $throwable::class,
99                $throwable->getMessage(),
100            ));
101
102            return $containerBuilderFactory();
103        }
104    }
105
106    /**
107     * The class name embeds a cache-directory hash, so containers dumped into
108     * different directories never collide inside one PHP process.
109     */
110    private function containerClass(): string
111    {
112        return 'PMFCompiledContainer_' . substr(md5($this->cacheDir), offset: 0, length: 12);
113    }
114
115    private function writeCache(string $cacheFile, string $dump): void
116    {
117        $temporaryFile = tempnam($this->cacheDir, prefix: 'container');
118        if ($temporaryFile === false) {
119            return;
120        }
121
122        file_put_contents($temporaryFile, $dump);
123        rename($temporaryFile, $cacheFile);
124    }
125}