From 98aff700fecb7f4417f5488fca52638831b1ed5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Fri, 23 Oct 2026 23:47:50 +0200 Subject: [PATCH 1/2] perf(kernel): resolve configuration and cache path once per boot AbstractKernel built a new KernelConfigurationResolver for every call, so the cache path (realpath walks plus permission checks) was resolved two to three times per request and the root composer.json was parsed for package prefixes on every request. The kernel now keeps one resolver and one resolved cache path per boot, the resolver memoizes the package prefixes, and bundle discovery receives the resolved cache directory instead of resolving it again for the manifest. --- src/Discovery/BundleDiscovery.php | 8 ++++++-- src/Discovery/KernelPackageManifestCache.php | 6 ++++++ src/Kernel/AbstractKernel.php | 12 +++++++++-- src/Kernel/KernelConfigurationResolver.php | 20 ++++++++++++++----- .../KernelPackageManifestCacheTest.php | 14 +++++++++++++ 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/Discovery/BundleDiscovery.php b/src/Discovery/BundleDiscovery.php index 503e196..c4d7f45 100644 --- a/src/Discovery/BundleDiscovery.php +++ b/src/Discovery/BundleDiscovery.php @@ -42,12 +42,16 @@ final class BundleDiscovery /** @var array */ private array $metadataHashes = []; - /** @param list|array $packagePrefixes Optional package-name prefixes used to narrow discovery. */ + /** + * @param list|array $packagePrefixes Optional package-name prefixes used to narrow discovery. + * @param string|null $cacheDir Resolved kernel cache directory; resolved again from the project when omitted. + */ public function __construct( private readonly ActivePackageResolver $resolver, array $packagePrefixes = [], private readonly ?string $projectDir = null, private readonly ?string $environment = null, + private readonly ?string $cacheDir = null, ) { $this->packagePrefixes = $this->normalizePackagePrefixes($packagePrefixes); @@ -539,7 +543,7 @@ private function packageNames(): array return $this->packageNames; } - $cache = new KernelPackageManifestCache($this->projectDir, $this->environment, $this->packagePrefixes); + $cache = new KernelPackageManifestCache($this->projectDir, $this->environment, $this->packagePrefixes, $this->cacheDir); $cachedPackages = $cache->read(); if ($cachedPackages !== null) { diff --git a/src/Discovery/KernelPackageManifestCache.php b/src/Discovery/KernelPackageManifestCache.php index cb089d6..b0c7975 100644 --- a/src/Discovery/KernelPackageManifestCache.php +++ b/src/Discovery/KernelPackageManifestCache.php @@ -22,11 +22,13 @@ public function metadata(): array /** * @param list $packagePrefixes + * @param string|null $cacheDir Resolved kernel cache directory; resolved from the project when omitted. */ public function __construct( private readonly ?string $projectDir, private readonly ?string $environment, private readonly array $packagePrefixes, + private readonly ?string $cacheDir = null, ) { } @@ -150,6 +152,10 @@ private function cacheFile(): ?string return null; } + if ($this->cacheDir !== null && $this->cacheDir !== '') { + return rtrim($this->cacheDir, '/') . '/discovery-packages.json'; + } + $environment = $this->environment; if ($environment === null || $environment === '') { diff --git a/src/Kernel/AbstractKernel.php b/src/Kernel/AbstractKernel.php index 45c52c9..67840fb 100644 --- a/src/Kernel/AbstractKernel.php +++ b/src/Kernel/AbstractKernel.php @@ -44,6 +44,8 @@ abstract class AbstractKernel implements KernelInterface protected array $bundles = []; private ?CoreServiceRegistrar $coreServiceRegistrar = null; + private ?KernelConfigurationResolver $configuration = null; + private ?string $cacheDir = null; public function __construct( protected readonly string $projectDir, @@ -76,6 +78,8 @@ public function __clone() $this->bundleRegistry = null; $this->bundles = []; $this->coreServiceRegistrar = null; + $this->configuration = null; + $this->cacheDir = null; } /** @return array{project_dir: string, environment: string, debug: bool} */ @@ -124,7 +128,8 @@ public function getCharset(): string public function getCacheDir(): string { - return $this->configuration()->cacheDir(); + // Resolving the cache path walks realpath() and permission checks; do it once per boot. + return $this->cacheDir ??= $this->configuration()->cacheDir(); } public function getBuildDir(): string @@ -265,6 +270,7 @@ public function discoverBundles(): BundleRegistry $this->configuration()->packagePrefixes(), $this->projectDir, $this->environment, + $this->getCacheDir(), ))->discover(); $this->bundles = $this->bundleMap($this->bundleRegistry); @@ -373,7 +379,7 @@ private function containerCacheManager(): ContainerCacheManager private function configuration(): KernelConfigurationResolver { - return new KernelConfigurationResolver( + return $this->configuration ??= new KernelConfigurationResolver( $this->projectDir, $this->environment, $this->config, @@ -433,6 +439,8 @@ public function shutdown(): void $this->container = null; $this->bundleRegistry = null; $this->bundles = []; + $this->configuration = null; + $this->cacheDir = null; } /** @return array */ diff --git a/src/Kernel/KernelConfigurationResolver.php b/src/Kernel/KernelConfigurationResolver.php index fd67fd1..6b365ff 100644 --- a/src/Kernel/KernelConfigurationResolver.php +++ b/src/Kernel/KernelConfigurationResolver.php @@ -7,12 +7,15 @@ use SymPress\Kernel\Bundle\BundleRegistry; use SymPress\Kernel\SiteConfig; -final readonly class KernelConfigurationResolver +final class KernelConfigurationResolver { + /** @var list|null */ + private ?array $packagePrefixes = null; + public function __construct( - private string $projectDir, - private string $environment, - private SiteConfig $config, + private readonly string $projectDir, + private readonly string $environment, + private readonly SiteConfig $config, ) { } @@ -65,13 +68,20 @@ public function logDir(): ?string /** @return list */ public function packagePrefixes(): array { + if ($this->packagePrefixes !== null) { + return $this->packagePrefixes; + } + $configured = $this->config->get('KERNEL_PACKAGE_PREFIXES', null); if ($configured === null) { + // The root composer.json is parsed once per resolver, not once per request phase. $configured = $this->composerKernelPackagePrefixes(); } - return $this->normalizePackagePrefixes($configured); + $this->packagePrefixes = $this->normalizePackagePrefixes($configured); + + return $this->packagePrefixes; } /** @return list */ diff --git a/tests/Discovery/KernelPackageManifestCacheTest.php b/tests/Discovery/KernelPackageManifestCacheTest.php index 8d9cf57..51013c0 100644 --- a/tests/Discovery/KernelPackageManifestCacheTest.php +++ b/tests/Discovery/KernelPackageManifestCacheTest.php @@ -42,6 +42,20 @@ public function testManifestRoundTripsUntilComposerMetadataChanges(): void self::assertNull($cache->read()); } + public function testExplicitCacheDirectorySkipsResolvingTheProjectCachePath(): void + { + $projectDir = $this->tmpPath('discovery-cache-explicit'); + file_put_contents("{$projectDir}/composer.json", '{}'); + $cacheDir = "{$projectDir}/resolved/kernel"; + + $cache = new KernelPackageManifestCache($projectDir, 'production', ['sympress/'], $cacheDir); + $cache->write(['sympress/kernel']); + + self::assertFileExists("{$cacheDir}/discovery-packages.json"); + self::assertFileDoesNotExist("{$projectDir}/var/cache/production/kernel/discovery-packages.json"); + self::assertSame(['sympress/kernel'], $cache->read()); + } + public function testUnchangedManifestReadersDoNotInvalidateOpcache(): void { $project = $this->tmpPath('discovery-reader-cost'); From 09b83f9cea6ea9bd3524869a412ef6469efb5cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sat, 24 Oct 2026 00:07:15 +0200 Subject: [PATCH 2/2] perf(kernel): memoize the cache path in the resolver, within the property limit --- src/Kernel/AbstractKernel.php | 7 ++----- src/Kernel/KernelConfigurationResolver.php | 4 +++- tests/Kernel/RuntimeCacheCompatibilityTest.php | 9 +++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Kernel/AbstractKernel.php b/src/Kernel/AbstractKernel.php index 67840fb..7ab9481 100644 --- a/src/Kernel/AbstractKernel.php +++ b/src/Kernel/AbstractKernel.php @@ -31,6 +31,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelInterface; +// phpcs:ignore SymPress.Classes.PropertyLimit.TooManyProperties -- The resolver is kept for one boot so the cache path is resolved once per request. abstract class AbstractKernel implements KernelInterface { protected readonly SiteConfig $config; @@ -45,7 +46,6 @@ abstract class AbstractKernel implements KernelInterface private ?CoreServiceRegistrar $coreServiceRegistrar = null; private ?KernelConfigurationResolver $configuration = null; - private ?string $cacheDir = null; public function __construct( protected readonly string $projectDir, @@ -79,7 +79,6 @@ public function __clone() $this->bundles = []; $this->coreServiceRegistrar = null; $this->configuration = null; - $this->cacheDir = null; } /** @return array{project_dir: string, environment: string, debug: bool} */ @@ -128,8 +127,7 @@ public function getCharset(): string public function getCacheDir(): string { - // Resolving the cache path walks realpath() and permission checks; do it once per boot. - return $this->cacheDir ??= $this->configuration()->cacheDir(); + return $this->configuration()->cacheDir(); } public function getBuildDir(): string @@ -440,7 +438,6 @@ public function shutdown(): void $this->bundleRegistry = null; $this->bundles = []; $this->configuration = null; - $this->cacheDir = null; } /** @return array */ diff --git a/src/Kernel/KernelConfigurationResolver.php b/src/Kernel/KernelConfigurationResolver.php index 6b365ff..acf2a38 100644 --- a/src/Kernel/KernelConfigurationResolver.php +++ b/src/Kernel/KernelConfigurationResolver.php @@ -11,6 +11,7 @@ final class KernelConfigurationResolver { /** @var list|null */ private ?array $packagePrefixes = null; + private ?string $cacheDir = null; public function __construct( private readonly string $projectDir, @@ -21,7 +22,8 @@ public function __construct( public function cacheDir(): string { - return CachePath::resolve($this->projectDir, $this->environment, $this->serverString('APP_CACHE_DIR')); + // Resolving the path walks realpath() and permission checks; a resolver lives for one boot. + return $this->cacheDir ??= CachePath::resolve($this->projectDir, $this->environment, $this->serverString('APP_CACHE_DIR')); } public function buildDir(): string diff --git a/tests/Kernel/RuntimeCacheCompatibilityTest.php b/tests/Kernel/RuntimeCacheCompatibilityTest.php index c8450df..b0fefc4 100644 --- a/tests/Kernel/RuntimeCacheCompatibilityTest.php +++ b/tests/Kernel/RuntimeCacheCompatibilityTest.php @@ -406,12 +406,13 @@ public function testCacheDirectoryReadsRealEnvironmentWithServerAndEnvPrecedence $previous = getenv('APP_CACHE_DIR'); putenv('APP_CACHE_DIR=' . $project . '/external'); try { - $resolver = new KernelConfigurationResolver($project, 'test', new TestSiteConfig('test')); - self::assertSame($project . '/external/test/kernel', $resolver->cacheDir()); + // A resolver fixes its cache path for one boot; each precedence step uses a fresh one. + $resolver = static fn (): KernelConfigurationResolver => new KernelConfigurationResolver($project, 'test', new TestSiteConfig('test')); + self::assertSame($project . '/external/test/kernel', $resolver()->cacheDir()); $_ENV['APP_CACHE_DIR'] = $project . '/env'; - self::assertSame($project . '/env/test/kernel', $resolver->cacheDir()); + self::assertSame($project . '/env/test/kernel', $resolver()->cacheDir()); $_SERVER['APP_CACHE_DIR'] = $project . '/server'; - self::assertSame($project . '/server/test/kernel', $resolver->cacheDir()); + self::assertSame($project . '/server/test/kernel', $resolver()->cacheDir()); } finally { unset($_SERVER['APP_CACHE_DIR'], $_ENV['APP_CACHE_DIR']); putenv($previous === false ? 'APP_CACHE_DIR' : 'APP_CACHE_DIR=' . $previous);