Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.74% covered (success)
89.74%
35 / 39
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilesystemConfigurationCache
89.74% covered (success)
89.74%
35 / 39
60.00% covered (warning)
60.00%
3 / 5
18.35
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 createIfEnabled
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 read
86.67% covered (success)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
8.15
 warm
84.62% covered (success)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
5.09
 clear
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Filesystem-backed configuration cache
5 *
6 * Caches the faqconfig rows in the PSR-6 filesystem pool so installs without Redis
7 * do not scan the full configuration table on every request. Writes through the
8 * HybridConfigurationStore invalidate the cache; a short TTL bounds staleness for
9 * multi-server setups where another node changes the configuration.
10 *
11 * This Source Code Form is subject to the terms of the Mozilla Public License,
12 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
13 * obtain one at https://mozilla.org/MPL/2.0/.
14 *
15 * @package   phpMyFAQ
16 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
17 * @copyright 2026 phpMyFAQ Team
18 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
19 * @link      https://www.phpmyfaq.de
20 * @since     2026-07-14
21 */
22
23declare(strict_types=1);
24
25namespace phpMyFAQ\Configuration\Storage;
26
27use Symfony\Component\Cache\Adapter\FilesystemAdapter;
28
29final class FilesystemConfigurationCache
30{
31    private const string CACHE_KEY = 'configuration-rows';
32
33    private const int DEFAULT_TTL = 300;
34
35    private readonly FilesystemAdapter $adapter;
36
37    /**
38     * @param string $identity Distinguishes tenants sharing one cache directory
39     *                         (multisite config dir, table prefix, table name).
40     */
41    public function __construct(string $cacheDir, string $identity, int $ttl = self::DEFAULT_TTL)
42    {
43        $this->adapter = new FilesystemAdapter(
44            namespace: 'pmf-config-' . substr(md5($identity), offset: 0, length: 12),
45            defaultLifetime: $ttl,
46            directory: $cacheDir,
47        );
48    }
49
50    /**
51     * Returns null (disabled) in debug mode or when CONFIG_CACHE_ENABLED is falsy.
52     */
53    public static function createIfEnabled(bool $debug, mixed $enabled, string $cacheDir, string $identity): ?self
54    {
55        if ($debug) {
56            return null;
57        }
58
59        if (!filter_var($enabled ?? 'true', FILTER_VALIDATE_BOOLEAN)) {
60            return null;
61        }
62
63        return new self($cacheDir, $identity);
64    }
65
66    /**
67     * Returns the cached configuration rows, or null on a cache miss.
68     *
69     * @return array<int, \stdClass>|null
70     */
71    public function read(): ?array
72    {
73        $item = $this->adapter->getItem(self::CACHE_KEY);
74        if (!$item->isHit()) {
75            return null;
76        }
77
78        /** @var mixed $payload */
79        $payload = $item->get();
80        if (!is_array($payload) || $payload === []) {
81            return null;
82        }
83
84        $rows = [];
85        /** @var mixed $entry */
86        foreach ($payload as $entry) {
87            if (!is_array($entry) || !array_key_exists('config_name', $entry) || $entry['config_name'] === null) {
88                return null;
89            }
90
91            $rows[] = (object) [
92                'config_name' => (string) $entry['config_name'],
93                'config_value' => (string) ($entry['config_value'] ?? ''),
94            ];
95        }
96
97        return $rows;
98    }
99
100    /**
101     * @param array<int, \stdClass> $rows
102     */
103    public function warm(array $rows): void
104    {
105        $payload = [];
106        foreach ($rows as $row) {
107            if (!property_exists($row, 'config_name') || $row->config_name === null) {
108                continue;
109            }
110
111            $payload[] = [
112                'config_name' => (string) $row->config_name,
113                'config_value' => (string) ($row->config_value ?? ''),
114            ];
115        }
116
117        if ($payload === []) {
118            return;
119        }
120
121        $item = $this->adapter->getItem(self::CACHE_KEY);
122        $item->set($payload);
123        $this->adapter->save($item);
124    }
125
126    public function clear(): void
127    {
128        $this->adapter->deleteItem(self::CACHE_KEY);
129    }
130}