Skip to content

Commit 3ce16e5

Browse files
committed
[ADD] Performance optimization: cache dotenv to speed up bootstrap.
1 parent b60ece9 commit 3ce16e5

6 files changed

Lines changed: 378 additions & 76 deletions

File tree

assets/docs/changelog.txt

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,35 @@ This file shows the changes in recent releases of Evolution CMS. The most curren
22
development release, and is only shown to give an idea of what's currently in the pipeline.
33

44
Evolution CMS 3.5.1 (Jan 02, 2026)
5-
* [GitHub: f0ba72a86] - [FIX] Dates. (Seiger)
6-
* [GitHub: cc2fb6b9d] - [FIX] Fixes #2164: speed up TTFB performance. (Seiger)
7-
* [GitHub: 0eea7ad3b] - [FIX] style typo error (Dmi3yy)
8-
* [GitHub: 0c141c2ce] - [FIX] Phx inarray (Dmi3yy)
5+
* [GitHub: ] - [ADD] Performance optimization: cache dotenv to speed up bootstrap. (Seiger)
6+
* [GitHub: f0ba72a] - [FIX] Dates. (Seiger)
7+
* [GitHub: cc2fb6b] - [FIX] Fixes #2164: speed up TTFB performance. (Seiger)
8+
* [GitHub: 0eea7ad] - [FIX] style typo error (Dmi3yy)
9+
* [GitHub: 0c141c2] - [FIX] Phx inarray (Dmi3yy)
910

1011
Evolution CMS 3.5.0 (Dec 25, 2025)
11-
* [GitHub:#ff5dfb9e6] - Update logo animation color (Dmi3yy)
12-
* [GitHub:#a8d63c5a1] - [UPD] Logo (Dmi3yy)
13-
* [GitHub:#368863dd7] - [FIX] Helper data_is_json() for PHP 8.4. (Seiger)
14-
* [GitHub:#a4a36cd50] - [ADD] Blade icons (Dmi3yy)
15-
* [GitHub:#60f2d9be7] - [FIX] Tracy Debug for PHP 8.4. (Seiger)
16-
* [GitHub:#4f7bc49bc] - [FIX] SqlFormatter for PHP 8.4. (Seiger)
17-
* [GitHub:#c00ff21a2] - [FIX] Refactor support collations. (Seiger)
18-
* [GitHub:#456642666] - [UPD] Core to Laravel 12. (Seiger)
19-
* [GitHub:#d5337c275] - [DEL] Salo 2. (Seiger)
20-
* [GitHub:#02f122adf] - [FIX] DB Collation on WEB installer. (Seiger)
21-
* [GitHub:#d78638980] - [UPD] Evolution 3.5 new background (Dmi3yy)
22-
* [GitHub:#77a0cc400] - [UPD] login style (Dmi3yy)
23-
* [GitHub:#0302954b3] - [UPD] Font to roboto (Dmi3yy)
24-
* [GitHub:#344a84169] - [FIX] Console error in tree (Dmi3yy)
25-
* [GitHub:#c2bfdd262] - [FIX] Style in tree (Dmi3yy)
26-
* [GitHub:#510d42bac] - [UPD] update main icons to tabler (Dmi3yy)
27-
* [GitHub:#623355364] - [UPD] update left menu (Dmi3yy)
28-
* [GitHub:#6440598b0] - [UPD] update dashboard (Dmi3yy)
29-
* [GitHub:#5b9109304] - [ADD] Initial Migration. (Seiger)
30-
* [GitHub:#dfa70bc3b] - [ADD] Initial Seed. (Seiger)
31-
* [GitHub:#37b625b04] - [FIX] Warning EVO_INSTALL_TIME. (Seiger)
32-
* [GitHub:#76cf43272] - [ADD] Admin User seed. (Seiger)
12+
* [GitHub: ff5dfb9] - [UPD] logo animation color (Dmi3yy)
13+
* [GitHub: a8d63c5] - [UPD] Logo (Dmi3yy)
14+
* [GitHub: 368863d] - [FIX] Helper data_is_json() for PHP 8.4. (Seiger)
15+
* [GitHub: a4a36cd] - [ADD] Blade icons (Dmi3yy)
16+
* [GitHub: 60f2d9b] - [FIX] Tracy Debug for PHP 8.4. (Seiger)
17+
* [GitHub: 4f7bc49] - [FIX] SqlFormatter for PHP 8.4. (Seiger)
18+
* [GitHub: c00ff21] - [FIX] Refactor support collations. (Seiger)
19+
* [GitHub: 4566426] - [UPD] Core to Laravel 12. (Seiger)
20+
* [GitHub: d5337c2] - [DEL] Salo 2. (Seiger)
21+
* [GitHub: 02f122a] - [FIX] DB Collation on WEB installer. (Seiger)
22+
* [GitHub: d786389] - [UPD] Evolution 3.5 new background (Dmi3yy)
23+
* [GitHub: 77a0cc4] - [UPD] login style (Dmi3yy)
24+
* [GitHub: 0302954] - [UPD] Font to roboto (Dmi3yy)
25+
* [GitHub: 344a841] - [FIX] Console error in tree (Dmi3yy)
26+
* [GitHub: c2bfdd2] - [FIX] Style in tree (Dmi3yy)
27+
* [GitHub: 510d42b] - [UPD] update main icons to tabler (Dmi3yy)
28+
* [GitHub: 6233553] - [UPD] update left menu (Dmi3yy)
29+
* [GitHub: 6440598] - [UPD] update dashboard (Dmi3yy)
30+
* [GitHub: 5b91093] - [ADD] Initial Migration. (Seiger)
31+
* [GitHub: dfa70bc] - [ADD] Initial Seed. (Seiger)
32+
* [GitHub: 37b625b] - [FIX] Warning EVO_INSTALL_TIME. (Seiger)
33+
* [GitHub: 76cf432] - [ADD] Admin User seed. (Seiger)
3334

3435
Evolution CMS 3.3.0 (Nov 07, 2025)
3536
* !!! Notice: minimum PHP version 8.3. !!!

core/bootstrap.php

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,21 @@
99
unset($tmp);
1010
}
1111

12-
$envFile = __DIR__ . '/custom/.env';
13-
if (is_readable($envFile) && class_exists(Dotenv\Dotenv::class)) {
14-
/**
15-
* @see: https://github.com/vlucas/phpdotenv
16-
*/
17-
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/custom');
18-
$dotenv->load();
12+
try {
13+
\EvolutionCMS\Bootstrap\EnvCacheLoader::load(dirname(__DIR__));
14+
} catch (\Throwable) {
15+
$projectRoot = dirname(__DIR__);
16+
$envFile = $projectRoot . '/core/custom/.env';
17+
if (!is_readable($envFile)) {
18+
$envFile = $projectRoot . '/.env';
19+
}
20+
21+
if (is_readable($envFile) && class_exists(\Dotenv\Dotenv::class)) {
22+
\Dotenv\Dotenv::createImmutable(dirname($envFile), basename($envFile))->load();
23+
}
24+
25+
unset($projectRoot, $envFile);
1926
}
20-
unset($envFile, $dotenv);
2127

2228
if (file_exists(__DIR__ . '/custom/define.php')) {
2329
require_once __DIR__ . '/custom/define.php';
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
<?php namespace EvolutionCMS\Bootstrap;
2+
3+
use Dotenv\Dotenv;
4+
use Throwable;
5+
6+
/**
7+
* `.env` loader with a PHP array cache.
8+
*
9+
* Cache file:
10+
* - `core/storage/cache/env.php` (relative to project root)
11+
*
12+
* Invalidation:
13+
* - Cache is considered valid when `filemtime(cache) >= filemtime(.env)`.
14+
* - Cache should be removed by the Manager “Refresh site / Clear cache” action (a=26) so it rebuilds automatically.
15+
*/
16+
final class EnvCacheLoader
17+
{
18+
/**
19+
* Loads environment variables with caching.
20+
*
21+
* Behavior:
22+
* - Detects an `.env` file (project-specific search order is implemented in {@see detectEnvPathAndMtime()}).
23+
* - If `core/storage/cache/env.php` exists and is fresh (`mtime(cache) >= mtime(.env)`), loads it.
24+
* - Otherwise parses `.env`, applies variables, and writes an atomic cache file.
25+
*
26+
* Compatibility:
27+
* - Applies variables "immutably": does not overwrite keys already present in `$_ENV` or `$_SERVER`.
28+
* - Optionally calls `putenv("$name=$value")` only when `getenv($name) === false`, to avoid overwriting
29+
* existing OS-level env values while still supporting legacy code that reads only via `getenv()`.
30+
*
31+
* Safety:
32+
* - Best-effort only; never throws (all internal failures are swallowed).
33+
* - If the cache directory is not writable, falls back to the project’s existing Dotenv loading.
34+
*/
35+
public static function load(string $projectRoot): void
36+
{
37+
$projectRoot = rtrim($projectRoot, '/');
38+
if ($projectRoot === '') {
39+
return;
40+
}
41+
42+
[$envPath, $envMtime] = self::detectEnvPathAndMtime($projectRoot);
43+
if ($envPath === null || $envMtime === null) {
44+
return;
45+
}
46+
47+
$cachePath = $projectRoot . '/core/storage/cache/env.php';
48+
49+
$cacheMtime = @filemtime($cachePath);
50+
if ($cacheMtime !== false && $cacheMtime >= $envMtime) {
51+
$cached = self::loadCacheArray($cachePath);
52+
if (is_array($cached)) {
53+
self::applyImmutable(self::normalizeVarsForCache($cached));
54+
return;
55+
}
56+
}
57+
58+
self::rebuildAndLoad($envPath, $cachePath);
59+
}
60+
61+
/**
62+
* Detects the `.env` file path and its mtime.
63+
*
64+
* Search order (project-specific):
65+
* 1) `{projectRoot}/core/custom/.env`
66+
* 2) `{projectRoot}/.env`
67+
*
68+
* @return array{0: string|null, 1: int|null}
69+
*/
70+
private static function detectEnvPathAndMtime(string $projectRoot): array
71+
{
72+
$coreCustomEnv = $projectRoot . '/core/custom/.env';
73+
$mtime = @filemtime($coreCustomEnv);
74+
if ($mtime !== false) {
75+
return [$coreCustomEnv, $mtime];
76+
}
77+
78+
$rootEnv = $projectRoot . '/.env';
79+
$mtime = @filemtime($rootEnv);
80+
if ($mtime !== false) {
81+
return [$rootEnv, $mtime];
82+
}
83+
84+
return [null, null];
85+
}
86+
87+
/**
88+
* Loads the cached env array from disk.
89+
*
90+
* Cache format:
91+
* - A PHP file returning an array: `<?php return ['KEY' => 'value', ...];`
92+
*
93+
* @return array<string, string|null>|null
94+
*/
95+
private static function loadCacheArray(string $cachePath): ?array
96+
{
97+
try {
98+
$data = require $cachePath;
99+
return is_array($data) ? $data : null;
100+
} catch (Throwable) {
101+
return null;
102+
}
103+
}
104+
105+
/**
106+
* Rebuilds the cache from `.env` (if possible) and applies the resulting variables.
107+
*
108+
* Flow:
109+
* - Ensures the cache directory exists and is writable; otherwise falls back to Dotenv load (no caching).
110+
* - Parses `.env` content (without mutating env) using `Dotenv::parse(...)`.
111+
* - Normalizes and applies the final variables.
112+
* - Writes cache atomically.
113+
*
114+
* @param string $envPath Absolute path to `.env`.
115+
* @param string $cachePath Absolute path to `core/storage/cache/env.php`.
116+
*/
117+
private static function rebuildAndLoad(string $envPath, string $cachePath): void
118+
{
119+
if (!class_exists(Dotenv::class)) {
120+
return;
121+
}
122+
123+
$cacheDir = dirname($cachePath);
124+
if (!is_dir($cacheDir) && !@mkdir($cacheDir, 0777, true) && !is_dir($cacheDir)) {
125+
self::fallbackDotenvLoad($envPath);
126+
return;
127+
}
128+
129+
if (!is_writable($cacheDir)) {
130+
self::fallbackDotenvLoad($envPath);
131+
return;
132+
}
133+
134+
$content = @file_get_contents($envPath);
135+
if (!is_string($content)) {
136+
return;
137+
}
138+
139+
try {
140+
$parsed = Dotenv::parse($content);
141+
} catch (Throwable) {
142+
return;
143+
}
144+
145+
$normalized = self::normalizeVarsForCache($parsed);
146+
self::applyImmutable($normalized);
147+
148+
self::writeCacheAtomic($cachePath, $normalized);
149+
}
150+
151+
/**
152+
* Falls back to the original Dotenv behavior (no caching).
153+
*
154+
* This is used when cache directory creation/writability checks fail. It is intentionally best-effort and
155+
* must not break the request.
156+
*/
157+
private static function fallbackDotenvLoad(string $envPath): void
158+
{
159+
try {
160+
Dotenv::createImmutable(dirname($envPath), basename($envPath))->load();
161+
} catch (Throwable) {
162+
// Ignore
163+
}
164+
}
165+
166+
/**
167+
* Apply values like Dotenv::createImmutable(...)->load() in this project:
168+
* - do not overwrite already-present variables
169+
* - do not write null values (treated as "not set")
170+
*
171+
* Also attempts to make values visible to legacy `getenv()` calls by calling `putenv()` only when the
172+
* variable is not already present at the OS/env level (`getenv($name) === false`).
173+
*
174+
* @param array<string, string> $vars
175+
*/
176+
private static function applyImmutable(array $vars): void
177+
{
178+
foreach ($vars as $name => $value) {
179+
if (!is_string($name) || $name === '' || !is_string($value)) {
180+
continue;
181+
}
182+
183+
if (array_key_exists($name, $_ENV) || array_key_exists($name, $_SERVER)) {
184+
continue;
185+
}
186+
187+
$_ENV[$name] = $value;
188+
$_SERVER[$name] = $value;
189+
190+
if (function_exists('putenv') && getenv($name) === false) {
191+
@putenv($name . '=' . $value);
192+
}
193+
}
194+
}
195+
196+
/**
197+
* Writes the env cache atomically.
198+
*
199+
* Implementation details:
200+
* - Writes to `{cachePath}.tmp` using `LOCK_EX` to avoid concurrent partial writes.
201+
* - Renames the temp file into place using `rename()` (atomic on most filesystems when on the same volume).
202+
*
203+
* Safety:
204+
* - Best-effort only; failures are ignored.
205+
*
206+
* @param array<string, string> $vars
207+
*/
208+
private static function writeCacheAtomic(string $cachePath, array $vars): void
209+
{
210+
try {
211+
ksort($vars, SORT_STRING);
212+
$php = "<?php\nreturn " . self::exportShortArray($vars) . ";\n";
213+
214+
$tmpPath = $cachePath . '.tmp';
215+
if (@file_put_contents($tmpPath, $php, LOCK_EX) === false) {
216+
return;
217+
}
218+
219+
@rename($tmpPath, $cachePath);
220+
} catch (Throwable) {
221+
// Ignore
222+
}
223+
}
224+
225+
/**
226+
* Converts an array into a readable, stable PHP array literal using short array syntax.
227+
*
228+
* @param array<string, string> $vars
229+
* @return string PHP code fragment for the array only (no `<?php` wrapper).
230+
*/
231+
private static function exportShortArray(array $vars): string
232+
{
233+
$lines = [];
234+
$lines[] = '[';
235+
foreach ($vars as $k => $v) {
236+
$lines[] = ' ' . var_export((string)$k, true) . ' => ' . var_export($v, true) . ',';
237+
}
238+
$lines[] = ']';
239+
return implode("\n", $lines);
240+
}
241+
242+
/**
243+
* Cache only the final key/value pairs that are actually applied.
244+
*
245+
* - Drops `null` values (Dotenv treats them as "not set")
246+
* - Keeps empty strings (they are real values in Dotenv)
247+
*
248+
* @param array<string, mixed> $vars
249+
* @return array<string, string>
250+
*/
251+
private static function normalizeVarsForCache(array $vars): array
252+
{
253+
$out = [];
254+
foreach ($vars as $name => $value) {
255+
if (!is_string($name) || $name === '' || $value === null) {
256+
continue;
257+
}
258+
if (is_string($value)) {
259+
$out[$name] = $value;
260+
}
261+
}
262+
return $out;
263+
}
264+
}

core/src/Controllers/RefreshSite.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ public function canView(): bool
3535
return true;
3636
}
3737

38-
public function process() : bool
38+
/**
39+
* Updates the site: (de)publishes documents, clears the cache, and invalidates the env cache.
40+
*
41+
* After clearing the cache, deletes `core/storage/cache/env.php` so that the `.env` cache is rebuilt on the next request.
42+
*/
43+
public function process(): bool
3944
{
4045
// (un)publishing of documents, version 2!
4146
// first, publish document waiting to be published
@@ -47,10 +52,15 @@ public function process() : bool
4752
];
4853

4954
ob_start();
50-
$this->managerTheme->getCore()->clearCache('full', true);
51-
$this->parameters['cache_log'] = ob_get_contents();
55+
$this->managerTheme->getCore()->clearCache('full', true);
56+
$this->parameters['cache_log'] = ob_get_contents();
5257
ob_end_clean();
5358

59+
$envCache = EVO_BASE_PATH . 'core/storage/cache/env.php';
60+
if (is_file($envCache)) {
61+
@unlink($envCache);
62+
}
63+
5464
// invoke OnSiteRefresh event
5565
$this->managerTheme->getCore()->invokeEvent("OnSiteRefresh");
5666

0 commit comments

Comments
 (0)